← back to Goldleafwallpaper

scripts/ig-poster/cncp-alert.js

95 lines

'use strict';
/*
 * cncp-alert.js — post a CNCP parking-lot card and REPORT whether it landed.
 *
 * WHY THIS EXISTS (CLAUDE.md TK-11431, amendment 2: "alert delivery is a
 * FINDING, not a log line" / "never hand-roll a curl alert arm")
 * ---------------------------------------------------------------------------
 * post-next.js used to alert on an expired IG token like this:
 *
 *     try {
 *       await fetch('http://127.0.0.1:3333/api/parking-lot', {...}).catch(() => {});
 *     } catch (_) {}
 *     console.error('ALERT: ... posted CNCP card. Re-auth needed.');
 *
 * Three independent ways that printed a FALSE success claim:
 *   (a) `.catch(() => {})` swallowed any network failure;
 *   (b) the outer `catch (_) {}` swallowed everything else;
 *   (c) fetch() RESOLVES on HTTP 404/500 — it does not throw on a non-2xx — so a
 *       moved/renamed endpoint means NOTHING throws and the success line prints
 *       anyway. That is the exact class that left two sibling canaries POSTing
 *       into a 404 for months while logging success.
 *
 * This matters more here than on a canary: this is the ONLY alarm on a LIVE
 * Instagram publisher. launchd-job-canary does NOT cover it — scan.sh:66 flags
 * only exit 127 or a hard-failure stderr signature, and its BROKEN_RE has no
 * alternative matching Meta's wording ("Error validating access token" /
 * "OAuthException" / code 190). So post-next.js's exit(2) is NOT flagged: if
 * this card never lands, the poster is silently dead until someone looks.
 *
 * The fix is to route through the SHARED sender (~/.claude/skills/_shared/
 * cncp_post.sh) rather than hand-rolling curl/fetch again. That sender:
 *   - remaps the payload to the two fields the handler actually reads
 *     (url, note) — {title,...} alone is a permanent 400;
 *   - ASSERTS a 2xx via `curl -w '%{http_code}'` and returns non-zero otherwise;
 *   - emits a delivery RECEIPT at the chokepoint, which the producer cannot
 *     suppress by forgetting.
 *
 * RECEIPT LOCATION CAVEAT (honest, measured): alert_receipt.sh resolves the
 * receipt dir from $SKILL, else by walking BASH_SOURCE up to a "skills" parent.
 * goldleafwallpaper is a PROJECT, not a skill, so there is no honest $SKILL to
 * set and the receipt lands in the shared bucket
 * ~/.claude/skills/_shared/data/alert-delivery.jsonl. fleet-health-rollup reads
 * receipts per SKILL row, and _shared has no data/latest.json, so that receipt
 * is durable but NOT surfaced on the morning panel. The project-local durable
 * record is therefore the caller's own post-log.jsonl line plus loud stderr.
 *
 * TEST SEAM: CNCP_URL (honoured by cncp_post.sh itself) points the POST at a
 * controlled local stub. Nothing here reads a --test flag, and no plist sets
 * CNCP_URL, so a scheduled run always targets the real board.
 *
 * Cost: $0 (local POST to 127.0.0.1).
 */
const path = require('path');
const { execFileSync } = require('child_process');

const SENDER = path.join(
  process.env.HOME || '', '.claude', 'skills', '_shared', 'cncp_post.sh');

/**
 * Post one parking-lot card.
 * @returns {{delivered: boolean, error: string|null}} — delivered:true ONLY when
 *   the shared sender verified a 2xx. Every other outcome (transport failure,
 *   non-2xx, missing sender, missing jq, timeout) returns delivered:false with a
 *   reason. NEVER throws: an alert arm must not be able to mask the underlying
 *   failure it is reporting.
 */
function postCncpCard(url, note, opts) {
  const o = opts || {};
  const sender = o.sender || SENDER;
  try {
    execFileSync(
      '/bin/bash',
      ['-c', '. "$CNCP_SENDER" && cncp_post "$CNCP_CARD_URL" "$CNCP_CARD_NOTE"'],
      {
        env: Object.assign({}, process.env, {
          CNCP_SENDER: sender,
          CNCP_CARD_URL: String(url || ''),
          CNCP_CARD_NOTE: String(note || ''),
        }),
        stdio: 'pipe',
        timeout: o.timeoutMs || 15000,
      },
    );
    return { delivered: true, error: null };
  } catch (e) {
    // cncp_post prints its reason (HTTP code / transport / jq) on stderr and
    // returns non-zero, so bash exits non-zero and execFileSync throws here.
    const se = e && e.stderr ? String(e.stderr).trim() : '';
    const reason = se || String((e && e.message) || e);
    return { delivered: false, error: reason.replace(/\s+/g, ' ').slice(0, 300) };
  }
}

module.exports = { postCncpCard, SENDER };