[object Object]

← back to Norma

instagram-agent: make reel skill account-aware (close cross-brand IG-token leak)

68552cedebfeed4f6ffd1efdbeccc89a3b9b97ea · 2026-07-13 10:24:27 -0700 · Steve Abrams

- resolveAccount(account): 'dw' → IG_USER_ID/IG_ACCESS_TOKEN (unchanged);
  other id X → IG_USER_ID_<X> / IG_ACCESS_TOKEN_<X> (X=upper, dashes→underscores),
  mirroring dw-marketing-reels publish-social.mjs IG_AGENT_AUTH_<ID> naming.
- non-dw account with NO token now returns { status:'no-creds-for-account',
  account, note } instead of silently posting from the DW account (the bug).
- echoes resolved account in response + logs; dw single-account simulation/live
  path 100% intact. node --check passes; verified via in-process skill calls.

Files touched

Diff

commit 68552cedebfeed4f6ffd1efdbeccc89a3b9b97ea
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jul 13 10:24:27 2026 -0700

    instagram-agent: make reel skill account-aware (close cross-brand IG-token leak)
    
    - resolveAccount(account): 'dw' → IG_USER_ID/IG_ACCESS_TOKEN (unchanged);
      other id X → IG_USER_ID_<X> / IG_ACCESS_TOKEN_<X> (X=upper, dashes→underscores),
      mirroring dw-marketing-reels publish-social.mjs IG_AGENT_AUTH_<ID> naming.
    - non-dw account with NO token now returns { status:'no-creds-for-account',
      account, note } instead of silently posting from the DW account (the bug).
    - echoes resolved account in response + logs; dw single-account simulation/live
      path 100% intact. node --check passes; verified via in-process skill calls.
---
 agents/instagram-agent/skills/reel.js | 69 +++++++++++++++++++++++++++++++----
 1 file changed, 62 insertions(+), 7 deletions(-)

diff --git a/agents/instagram-agent/skills/reel.js b/agents/instagram-agent/skills/reel.js
index 0e0153c..5053d20 100644
--- a/agents/instagram-agent/skills/reel.js
+++ b/agents/instagram-agent/skills/reel.js
@@ -14,6 +14,34 @@ const ig = require('./_ig-api');
 const AGENT = 'instagram-agent';
 const PLATFORM = 'instagram';
 
+/**
+ * Resolve the IG credential pair for a given account id.
+ *
+ * The dw-marketing-reels publisher (scripts/publish-social.mjs) tags every
+ * reel with an `account` id ('dw', 'phillipe-romano', …) and names its own
+ * per-account auth header env as IG_AGENT_AUTH_<ID> where
+ * <ID> = account.toUpperCase().replace(/-/g, '_'). We MIRROR that exact
+ * naming here for the IG token pair so the two sides agree:
+ *
+ *   account 'dw'              → IG_USER_ID           + IG_ACCESS_TOKEN  (existing)
+ *   account 'phillipe-romano' → IG_USER_ID_PHILLIPE_ROMANO + IG_ACCESS_TOKEN_PHILLIPE_ROMANO
+ *
+ * Returns { account, userId, accessToken } — userId/accessToken are undefined
+ * when that account has no token configured (caller returns no-creds-for-account).
+ */
+function resolveAccount(account) {
+  const acct = (account || 'dw').toString().trim() || 'dw';
+  if (acct === 'dw') {
+    return { account: 'dw', userId: process.env.IG_USER_ID, accessToken: process.env.IG_ACCESS_TOKEN };
+  }
+  const suffix = acct.toUpperCase().replace(/-/g, '_');
+  return {
+    account: acct,
+    userId: process.env[`IG_USER_ID_${suffix}`],
+    accessToken: process.env[`IG_ACCESS_TOKEN_${suffix}`],
+  };
+}
+
 /**
  * @param {Object} params - Request body
  * @param {string} params.video_url - Public URL of the video file
@@ -21,28 +49,56 @@ const PLATFORM = 'instagram';
  * @param {boolean} [params.share_to_feed=true] - Also share to main feed
  * @param {string} [params.cover_url] - Cover image URL (optional)
  * @param {string} [params.pipeline_id] - Pipeline entry ID to link
+ * @param {string} [params.account='dw'] - dw-marketing-reels account id; selects which IG token pair to use
  * @returns {Promise<Object>}
  */
 module.exports = async function reel(params) {
   const videoUrl = params.video_url || '';
   const caption = params.caption || params.text || '';
   const shareToFeed = params.share_to_feed !== false;
-  const simulated = !ig.hasCredentials();
 
-  console.log(`[${AGENT}] Reel skill invoked — simulation=${simulated}`);
+  // Resolve WHICH account's credentials to use (default 'dw' = legacy behavior).
+  const resolved = resolveAccount(params.account);
+  const account = resolved.account;
+  const igUserId = resolved.userId;
+  const accessToken = resolved.accessToken;
+  const hasCreds = !!(igUserId && accessToken);
+  const simulated = !hasCreds;
+
+  console.log(`[${AGENT}] Reel skill invoked — account=${account} simulation=${simulated}`);
   console.log(`[${AGENT}] Caption: ${caption.substring(0, 100)}${caption.length > 100 ? '...' : ''}`);
   if (videoUrl) console.log(`[${AGENT}] Video URL: ${videoUrl}`);
 
+  // CORRECTNESS GATE: a NON-'dw' account that has no token of its own must NOT
+  // silently fall through to DW's token — posting brand X's reel from DW's
+  // account is a real cross-brand leak. Return a clear structured result.
+  if (account !== 'dw' && !hasCreds) {
+    const suffix = account.toUpperCase().replace(/-/g, '_');
+    console.log(`[${AGENT}] No credentials for account '${account}' — refusing to fall through to DW. (need IG_USER_ID_${suffix} + IG_ACCESS_TOKEN_${suffix})`);
+    return {
+      status: 'no-creds-for-account',
+      account,
+      note: `account ${account} has no IG_ACCESS_TOKEN_${suffix} configured`,
+      posted: false,
+      simulated: false,
+      platform: PLATFORM,
+      video_url: videoUrl || null,
+      pipeline_id: params.pipeline_id || null,
+      created_at: new Date().toISOString(),
+    };
+  }
+
   if (simulated) {
     // --- Simulation mode ---
     const simulatedContainerId = `ig_reel_container_${Date.now()}`;
     const simulatedMediaId = `ig_reel_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
 
-    console.log(`[${AGENT}] SIMULATED reel created: container=${simulatedContainerId}, media=${simulatedMediaId}`);
+    console.log(`[${AGENT}] SIMULATED reel created (account=${account}): container=${simulatedContainerId}, media=${simulatedMediaId}`);
 
     return {
       media_id: simulatedMediaId,
       container_id: simulatedContainerId,
+      account,
       caption,
       video_url: videoUrl || null,
       share_to_feed: shareToFeed,
@@ -57,11 +113,9 @@ module.exports = async function reel(params) {
   }
 
   // --- Real API call (Instagram API with Instagram Login) ---
+  // igUserId/accessToken were resolved above for `account` (creds guaranteed present here).
   if (!videoUrl) throw new Error('video_url is required to post a reel');
 
-  const igUserId = process.env.IG_USER_ID;
-  const accessToken = process.env.IG_ACCESS_TOKEN;
-
   // Step 1: create the reel container
   const containerBody = {
     media_type: 'REELS',
@@ -80,11 +134,12 @@ module.exports = async function reel(params) {
   // Step 3: publish
   const mediaId = await ig.publishContainer(igUserId, accessToken, containerId);
   const permalink = await ig.permalinkOf(mediaId, accessToken);
-  console.log(`[${AGENT}] Reel published: media=${mediaId} ${permalink || ''}`);
+  console.log(`[${AGENT}] Reel published (account=${account}): media=${mediaId} ${permalink || ''}`);
 
   return {
     media_id: mediaId,
     container_id: containerId,
+    account,
     caption,
     video_url: videoUrl,
     share_to_feed: shareToFeed,

← 5d84a60 chore: v0.1.3 (dashboard CSS refactor)  ·  back to Norma  ·  instagram-agent: env-configurable Graph host (IG_GRAPH_HOST) 05b28cd →