[object Object]

← back to Marketing Command Center

ig-activity: use shared ordered Norma auth (IG_AGENT_AUTH -> agent .env -> NORMA_IG_PASS) instead of stale NORMA_IG_PASS (TK-12342)

933b121acd129dd917a7cd2e105e98742b49ff57 · 2026-09-26 10:00:35 -0700 · Steve Abrams

- lib/norma-auth.js: extract follow-counts' ordered-credential helper + normaFetch (401/403 falls through)
- follow-counts now imports the shared helper (no behavior change)
- ig-activity delete-status/delete use normaFetch; auth rejection now 502 ok:false, not 200 ok:true
- oc-status timeout 8s -> 20s (openclaw probe takes 5-14s)
- scripts/norma-auth-check.js: read-only check, exit 1 on auth fail, 2 unreachable

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

Files touched

Diff

commit 933b121acd129dd917a7cd2e105e98742b49ff57
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 10:00:35 2026 -0700

    ig-activity: use shared ordered Norma auth (IG_AGENT_AUTH -> agent .env -> NORMA_IG_PASS) instead of stale NORMA_IG_PASS (TK-12342)
    
    - lib/norma-auth.js: extract follow-counts' ordered-credential helper + normaFetch (401/403 falls through)
    - follow-counts now imports the shared helper (no behavior change)
    - ig-activity delete-status/delete use normaFetch; auth rejection now 502 ok:false, not 200 ok:true
    - oc-status timeout 8s -> 20s (openclaw probe takes 5-14s)
    - scripts/norma-auth-check.js: read-only check, exit 1 on auth fail, 2 unreachable
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01GEE4jWy9RBmz5QnR2xcvno
---
 lib/norma-auth.js              | 75 ++++++++++++++++++++++++++++++++++++++++++
 modules/follow-counts/index.js | 27 ++-------------
 modules/ig-activity/index.js   | 38 +++++++++++----------
 scripts/norma-auth-check.js    | 16 +++++++++
 4 files changed, 114 insertions(+), 42 deletions(-)

diff --git a/lib/norma-auth.js b/lib/norma-auth.js
new file mode 100644
index 0000000..4631102
--- /dev/null
+++ b/lib/norma-auth.js
@@ -0,0 +1,75 @@
+// Shared Norma instagram-agent (:9810) auth — ordered credential sources.
+//
+// Credential sources, tried in order (TK-12327, shared by TK-12342):
+//   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 call 403'd "Invalid credentials" (TK-12008, TK-12342).
+// A 401/403 on one source falls through to the next: a rejected auth never reached the
+// handler, so retrying with the next credential is side-effect free (safe even for POSTs).
+//
+// Env overrides (tests / relocation): NORMA_IG_BASE, SECRETS_MANAGER_ENV, NORMA_IG_AGENT_ENV.
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const { fetchWithTimeout } = require('./fetch-timeout.js');
+
+const normaBase = () => (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
+
+function readEnvFile(f) {
+  const out = {};
+  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 -> next source */ }
+  return out;
+}
+
+// -> [{ source, header }] in priority order, de-duplicated. Never logs secret values.
+function normaAuthCandidates() {
+  const home = 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;
+}
+
+// fetch `${NORMA_BASE}${urlPath}` trying each credential in order; 401/403 falls through.
+// Resolves { ok:true, res, json, source } on the first non-auth-rejected response
+// (res.ok may still be false for non-auth HTTP errors — caller decides), or
+// { ok:false, status, error, authRejected } when no source is configured / all are rejected.
+// Network errors (unreachable/timeout) throw, exactly like fetchWithTimeout.
+async function normaFetch(urlPath, opts = {}, ms, { tag = 'norma-auth', candidates } = {}) {
+  const cands = candidates || normaAuthCandidates();
+  if (!cands.length) {
+    return { ok: false, status: 0, authRejected: true,
+      error: 'Norma auth not configured (no IG_AGENT_AUTH in secrets-manager, no instagram-agent .env, no NORMA_IG_PASS)' };
+  }
+  let lastStatus = 0;
+  for (const cand of cands) {
+    const res = await fetchWithTimeout(`${normaBase()}${urlPath}`,
+      { ...opts, headers: { ...(opts.headers || {}), Authorization: cand.header } }, ms);
+    if (res.status === 401 || res.status === 403) {
+      lastStatus = res.status;
+      console.error(`[${tag}] ${new Date().toISOString()} auth via ${cand.source} rejected (HTTP ${res.status}); trying next source`);
+      continue;
+    }
+    const json = await res.json().catch(() => ({}));
+    return { ok: true, res, json, source: cand.source };
+  }
+  return { ok: false, status: lastStatus, authRejected: true,
+    error: `Norma auth rejected (HTTP ${lastStatus}) by every source: ${cands.map(c => c.source).join(', ')}` };
+}
+
+module.exports = { normaBase, readEnvFile, normaAuthCandidates, normaFetch };
diff --git a/modules/follow-counts/index.js b/modules/follow-counts/index.js
index 8df6e88..7640a2a 100644
--- a/modules/follow-counts/index.js
+++ b/modules/follow-counts/index.js
@@ -32,31 +32,8 @@ const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replac
 // 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 = {};
-  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 -> next source */ }
-  return out;
-}
-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;
-}
+// readEnvFile/normaAuthCandidates live in lib/norma-auth.js (shared with ig-activity, TK-12342).
+const { normaAuthCandidates } = require('../../lib/norma-auth.js');
 const NORMA_AUTH = normaAuthCandidates();
 
 // ── account roster ────────────────────────────────────────────────────────────
diff --git a/modules/ig-activity/index.js b/modules/ig-activity/index.js
index f9388d6..ae0a92b 100644
--- a/modules/ig-activity/index.js
+++ b/modules/ig-activity/index.js
@@ -14,20 +14,17 @@
 // We keep a local tombstone mirror so a removed post disappears from THIS board
 // immediately, even before the next snapshot regen.
 //
-// Self-contained per the MODULE CONTRACT. Reuses the shared timeout guard and the
-// same NORMA_IG_* env the follow-counts module already established.
+// Self-contained per the MODULE CONTRACT. Norma auth uses the SAME ordered-credential
+// helper as follow-counts (lib/norma-auth.js: secrets-manager IG_AGENT_AUTH, then the
+// instagram-agent .env, then NORMA_IG_PASS; 401/403 falls through). The old hardcoded
+// NORMA_IG_PASS went stale on Norma's password rotation and 403'd every call (TK-12342).
 const fs = require('fs');
 const path = require('path');
-const { fetchWithTimeout } = require('../../lib/fetch-timeout.js');
+const { normaBase, normaFetch } = require('../../lib/norma-auth.js');
 
 const ACTIVITY = path.join(__dirname, '..', '..', 'public', 'ig-activity.json');
 const TOMBSTONES = path.join(__dirname, '..', '..', 'data', 'ig-activity-tombstones.json');
 
-// ── Norma instagram-agent (reuse its plumbing/creds — see follow-counts) ──────
-const NORMA_BASE = (process.env.NORMA_IG_BASE || 'http://127.0.0.1:9810').replace(/\/$/, '');
-const NORMA_USER = process.env.NORMA_IG_USER || 'admin';
-const NORMA_PASS = process.env.NORMA_IG_PASS || '';
-const normaAuth = 'Basic ' + Buffer.from(`${NORMA_USER}:${NORMA_PASS}`).toString('base64');
 
 function loadActivity() {
   try { return JSON.parse(fs.readFileSync(ACTIVITY, 'utf8')); }
@@ -67,13 +64,19 @@ module.exports = {
     // Is Norma's real-Chrome session logged in + is live delete armed? (advisory
     // for the UI so it can tell the user whether a "real" delete will fire or 403.)
     router.get('/delete-status', async (_req, res) => {
+      const norma = normaBase();
       try {
-        const r = await fetchWithTimeout(`${NORMA_BASE}/api/posts/oc-status`,
-          { headers: { Authorization: normaAuth } }, 8000);
-        const j = await r.json().catch(() => ({}));
-        res.json({ ok: true, loggedIn: !!j.loggedIn, norma: NORMA_BASE });
+        const a = await normaFetch('/api/posts/oc-status', {}, 20000, { tag: 'ig-activity' }); // oc-status probes openclaw: 5-14s
+        // Auth rejected / non-2xx is a FAILURE, never ok:true+loggedIn:false (that read as
+        // "just logged out" and hid the stale-credential 403 — TK-12342).
+        if (!a.ok) return res.status(502).json({ ok: false, loggedIn: false, error: a.error, norma });
+        if (!a.res.ok) {
+          return res.status(502).json({ ok: false, loggedIn: false,
+            error: a.json.error || `Norma oc-status HTTP ${a.res.status}`, norma });
+        }
+        res.json({ ok: true, loggedIn: !!a.json.loggedIn, norma, auth: a.source });
       } catch (e) {
-        res.json({ ok: false, loggedIn: false, error: `Norma unreachable: ${e.message}`, norma: NORMA_BASE });
+        res.status(502).json({ ok: false, loggedIn: false, error: `Norma unreachable: ${e.message}`, norma });
       }
     });
 
@@ -85,12 +88,13 @@ module.exports = {
       const id = permalink || media_id;
       if (!id) return res.status(400).json({ ok: false, error: 'permalink or media_id required' });
       try {
-        const r = await fetchWithTimeout(`${NORMA_BASE}/api/posts/delete`, {
+        const a = await normaFetch('/api/posts/delete', {
           method: 'POST',
-          headers: { 'Content-Type': 'application/json', Authorization: normaAuth },
+          headers: { 'Content-Type': 'application/json' },
           body: JSON.stringify({ permalink, media_id, handle, live: !!live }),
-        }, live ? 120000 : 15000); // a real openclaw delete is slow; tombstone is fast
-        const j = await r.json().catch(() => ({}));
+        }, live ? 120000 : 15000, { tag: 'ig-activity' }); // a real openclaw delete is slow; tombstone is fast
+        if (!a.ok) return res.status(502).json({ ok: false, error: a.error });
+        const r = a.res, j = a.json;
         // Mirror to our local tombstone so it leaves THIS board immediately, on any
         // outcome Norma treats as removed (tombstone mode, or verified live delete).
         if (r.ok && j.ok && (j.mode === 'tombstone' || j.verified)) {
diff --git a/scripts/norma-auth-check.js b/scripts/norma-auth-check.js
new file mode 100644
index 0000000..feba449
--- /dev/null
+++ b/scripts/norma-auth-check.js
@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+// Read-only check that ig-activity's Norma auth path works (TK-12342).
+// GET /api/posts/oc-status via the shared ordered-auth helper (lib/norma-auth.js).
+// Exit 0 = authenticated 2xx; exit 1 = auth rejected by every source / not configured /
+// non-2xx; exit 2 = Norma unreachable. Prints the winning SOURCE name only — never a secret.
+const { normaBase, normaFetch } = require('../lib/norma-auth.js');
+(async () => {
+  try {
+    const a = await normaFetch('/api/posts/oc-status', {}, 20000, { tag: 'norma-auth-check' });
+    if (!a.ok) { console.error(`FAIL ${normaBase()}: ${a.error}`); process.exit(1); }
+    if (!a.res.ok) { console.error(`FAIL ${normaBase()}: HTTP ${a.res.status} ${a.json.error || ''}`); process.exit(1); }
+    console.log(`PASS ${normaBase()} HTTP ${a.res.status} via ${a.source} loggedIn=${!!a.json.loggedIn}`);
+  } catch (e) {
+    console.error(`FAIL ${normaBase()}: unreachable: ${e.message}`); process.exit(2);
+  }
+})();

← 25c7678 auto-data-snapshot: 2026-09-26T09:40:15 (1 data files) — pub  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-09-26T10:42:21 (1 data files) — pub c0f8776 →