← 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
A lib/norma-auth.jsM modules/follow-counts/index.jsM modules/ig-activity/index.jsA scripts/norma-auth-check.js
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 →