[object Object]

← back to Marketing Command Center

follow-counts: read Norma auth from secrets-manager IG_AGENT_AUTH first, fall through on 401/403 to instagram-agent .env then NORMA_IG_PASS (TK-12327)

f88c44a154a8db510b2ca93a1096d7572b3bbafe · 2026-09-26 09:16:40 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RZxSBvMaQ2irhbKdpmgRPj

Files touched

Diff

commit f88c44a154a8db510b2ca93a1096d7572b3bbafe
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 09:16:40 2026 -0700

    follow-counts: read Norma auth from secrets-manager IG_AGENT_AUTH first, fall through on 401/403 to instagram-agent .env then NORMA_IG_PASS (TK-12327)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01RZxSBvMaQ2irhbKdpmgRPj
---
 modules/follow-counts/index.js | 60 +++++++++++++++++++++++++++++++-----------
 1 file changed, 45 insertions(+), 15 deletions(-)

diff --git a/modules/follow-counts/index.js b/modules/follow-counts/index.js
index 3d73f95..8df6e88 100644
--- a/modules/follow-counts/index.js
+++ b/modules/follow-counts/index.js
@@ -24,27 +24,40 @@ const HISTORY = path.join(DATA, 'follow-counts-history.json');
 
 // ── Norma instagram-agent (reuse its token/IG-user-id/simulation) ─────────────
 const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
-// Credential source of truth = the instagram-agent's OWN .env (AUTH_USERNAME/AUTH_PASSWORD).
-// A copied NORMA_IG_PASS in this repo's .env went stale when Norma rotated its password after
-// the 2026-07-29 empty-password incident -> every snapshot 403'd "Invalid credentials"
-// (TK-12008). Read the agent's .env first so a future rotation can't silently re-break this;
-// NORMA_IG_PASS remains an explicit override only if the agent .env is unreadable.
-function readNormaAgentEnv() {
+// Credential sources, tried in order (TK-12327):
+//   1. secrets-manager master .env  IG_AGENT_AUTH  (canonical "Basic ..." header)
+//   2. the instagram-agent's OWN .env  AUTH_USERNAME/AUTH_PASSWORD
+//   3. NORMA_IG_USER/NORMA_IG_PASS from this repo's env (explicit override, last resort)
+// A copied NORMA_IG_PASS went stale when Norma rotated its password after the 2026-07-29
+// empty-password incident -> every snapshot 403'd "Invalid credentials" (TK-12008). So a
+// 401/403 on one source falls through to the next rather than failing the run: a rotation
+// that updates only one of secrets-manager / the agent .env can't silently re-break this.
+function readEnvFile(f) {
   const out = {};
-  const f = process.env.NORMA_IG_AGENT_ENV ||
-    path.join(require('os').homedir(), 'Projects', 'Norma', 'agents', 'instagram-agent', '.env');
   try {
     for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
       const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
       if (m) out[m[1]] = m[2].replace(/^["']|["']$/g, '');
     }
-  } catch { /* unreadable -> fall back to NORMA_IG_* */ }
+  } catch { /* unreadable -> next source */ }
   return out;
 }
-const _normaEnv = readNormaAgentEnv();
-const NORMA_USER = _normaEnv.AUTH_USERNAME || process.env.NORMA_IG_USER || 'admin';
-const NORMA_PASS = _normaEnv.AUTH_PASSWORD || process.env.NORMA_IG_PASS || '';
-const normaAuth = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
+function normaAuthCandidates() {
+  const home = require('os').homedir();
+  const basic = (u, p) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
+  const out = [];
+  const add = (source, header) => {
+    if (header && /^Basic \S+$/.test(header) && !out.some(c => c.header === header)) out.push({ source, header });
+  };
+  const sm = readEnvFile(process.env.SECRETS_MANAGER_ENV || path.join(home, 'Projects', 'secrets-manager', '.env'));
+  add('secrets-manager IG_AGENT_AUTH', (sm.IG_AGENT_AUTH || '').trim());
+  const agent = readEnvFile(process.env.NORMA_IG_AGENT_ENV ||
+    path.join(home, 'Projects', 'Norma', 'agents', 'instagram-agent', '.env'));
+  if (agent.AUTH_PASSWORD) add('instagram-agent .env', basic(agent.AUTH_USERNAME || 'admin', agent.AUTH_PASSWORD));
+  if (process.env.NORMA_IG_PASS) add('NORMA_IG_PASS env', basic(process.env.NORMA_IG_USER || 'admin', process.env.NORMA_IG_PASS));
+  return out;
+}
+const NORMA_AUTH = normaAuthCandidates();
 
 // ── account roster ────────────────────────────────────────────────────────────
 // The DW-owned IG accounts this tab tracks. Each: { handle, label, igUserId? }.
@@ -90,16 +103,33 @@ async function fetchCountsFromNorma(attempts = 4) {
   return last;
 }
 async function fetchCountsFromNormaOnce() {
+  if (!NORMA_AUTH.length) {
+    return { ok: false, error: 'Norma auth not configured (no IG_AGENT_AUTH in secrets-manager, no instagram-agent .env, no NORMA_IG_PASS)' };
+  }
+  let denied;
+  for (const cand of NORMA_AUTH) {
+    const r = await fetchCountsWithAuth(cand.header);
+    if (r.status === 401 || r.status === 403) {
+      console.error(`[follow-counts] ${new Date().toISOString()} auth via ${cand.source} rejected (HTTP ${r.status}); trying next source`);
+      denied = { ok: false, error: `${r.error} (auth rejected by every source: ${NORMA_AUTH.map(c => c.source).join(', ')})` };
+      continue;
+    }
+    delete r.status;
+    return r;
+  }
+  return denied;
+}
+async function fetchCountsWithAuth(authHeader) {
   const url = `${NORMA_BASE}/api/skill/monitor`;
   try {
     const r = await fetchWithTimeout(url, {
       method: 'POST',
-      headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
+      headers: { 'Content-Type': 'application/json', Authorization: authHeader },
       body: JSON.stringify({ period: 'day', push_to_pulse: false }),
     });
     const j = await r.json().catch(() => ({}));
     if (!r.ok || j.success === false) {
-      return { ok: false, error: j.error || `Norma monitor HTTP ${r.status}` };
+      return { ok: false, status: r.status, error: j.error || `Norma monitor HTTP ${r.status}` };
     }
     const acct = (j.result && j.result.account) || {};
     return {

← c82763c auto-data-snapshot: 2026-09-26T08:50:16 (3 data files) — dat  ·  back to Marketing Command Center  ·  TK-11856 rc-propagation (RSYNC): mcc-clients-refresh propaga af0204b →