← back to Kickbacks Ai Upstream

src/activation/adRotation.ts

266 lines

import type { PatchAd, PortfolioResponse } from "../portfolio/client";
import { fetchPortfolioWithDemoFallback } from "../portfolio/client";
import type { PortfolioClient } from "../portfolio/client";
import type { TargetAdapter, PatchParams } from "../adapters/types";
import type { DebugController } from "../debug";
import type { AuthClient } from "../auth/client";
import type { SessionState } from "../sessionState";
import { canPatch, servingVerdict } from "../servingGate";
import { dlog } from "../log";

export interface AdRotationDeps {
  adapter: TargetAdapter;
  portfolio: PortfolioClient;
  auth: AuthClient;
  debugCtl: DebugController;
  session: SessionState;
  ccVersion: string;
  port: number;
  patchParams: PatchParams;
  /** Mutable ref: the currently active ad. Updated in-place by rotation.
   *  Null after a sign-out clear until the next portfolio apply. */
  activeAdRef: { current: PatchAd | null };
  /** Mutable ref: the current correlation id. Updated on ad change. */
  corrRef: { current: string };
  /** Mutable ref: the outer ad variable (closure-scope in activate). */
  adRef: { current: PatchAd | null };
  impDedupe: { reset(): void };
  reapplyCodex: (() => void) | null;
  /** Fired after every ad apply (initial, rotation, sign-in demo→real swap) so
   *  other surfaces — notably the timer-driven CLI sync — can refresh NOW
   *  instead of waiting for their own poll. Best-effort; must never throw. */
  onAdApplied?: () => void;
  timers: NodeJS.Timeout[];
}

export interface AdRotationState {
  adQueue: PatchAd[];
  rotationIdx: number;
  rotationTimer: ReturnType<typeof setInterval> | null;
  lastAdSetSig: string;
  /** Monotonic refresh identity. Bumped by every FORCED refresh (the sign-in
   *  demo→real swap) and by the sign-out clear; an in-flight refresh whose
   *  captured epoch is stale by the time its fetch resolves discards the
   *  response instead of reinstating outdated (e.g. demo) ads over the
   *  just-applied ones. */
  refreshEpoch: number;
}

/** Apply a new ad to the adapter + Codex, updating all shared refs.
 *  `force` re-applies even when the adId is unchanged — needed for the
 *  signed-out→signed-in swap, where the demo ad and the real ad can share an
 *  adId but the real ad carries a real (user-crediting) session token that the
 *  overlay's `activeAd` MUST pick up, or it would keep billing the demo token. */
function applyAd(
  next: PatchAd,
  deps: AdRotationDeps,
  state: AdRotationState,
  force = false,
): boolean {
  // Serving gate (wave 2, audit #3/#9): the rotation tick and the 60s
  // refresh were the un-gated writers that re-patched a killed install right
  // after checkKill restored it. Killed / offline-frozen / user-disabled /
  // canary-suspended ⇒ no ref swaps, no patch write, no new ad served; the
  // 60s reassert re-applies on its own first healthy tick after recovery.
  // Returns false when gated so refreshPortfolio knows NOT to latch the ad-set
  // signature (an unchanged set must still re-apply after recovery).
  if (!canPatch()) {
    dlog("ext", "rotation.gated", { verdict: servingVerdict(), adId: next.adId });
    return false;
  }
  const adChanged = force || next.adId !== deps.adRef.current?.adId;
  deps.adRef.current = next;
  deps.session.set({ hasAd: true });
  deps.debugCtl?.setPortfolioAd(next.adText, next.clickUrl || "");
  if (adChanged && deps.port > 0) {
    deps.activeAdRef.current = next;
    deps.corrRef.current = next.adId + "." + Math.random().toString(36).slice(2, 8);
    deps.impDedupe.reset();
    Object.assign(deps.patchParams, { adText: next.adText,
      iconRef: next.iconRef, iconUrl: next.iconUrl,
      clickUrl: next.clickUrl });
    deps.adapter.applyPatch(deps.patchParams);
    deps.reapplyCodex?.();
    dlog("ext", "portfolio.rotated", { adId: next.adId, corr: deps.corrRef.current });
  }
  // Outside the adChanged/port guard on purpose: the forced sign-in swap shares
  // an adId yet still needs the CLI surface re-synced to the real ad.
  try { deps.onAdApplied?.(); } catch { /* best-effort */ }
  return true;
}

function rotateNext(deps: AdRotationDeps, state: AdRotationState): void {
  if (state.adQueue.length < 2) return;
  state.rotationIdx = (state.rotationIdx + 1) % state.adQueue.length;
  applyAd(state.adQueue[state.rotationIdx], deps, state);
}

async function refreshPortfolio(
  deps: AdRotationDeps,
  state: AdRotationState,
  force = false,
): Promise<void> {
  try {
    // Demo-aware: a signed-out client refreshes from the public demo portfolio
    // so the preview rotates like the live product. A signed-in client uses the
    // authed portfolio (real, user-crediting session tokens). `force` (the
    // sign-in swap) re-applies even when the adId set is unchanged so the
    // overlay drops the demo token for the real one.
    // A forced refresh starts a new epoch so any slower refresh already in
    // flight (e.g. the 60s timer's signed-out DEMO fetch racing the sign-in
    // swap) is recognised as stale below and discarded instead of applied.
    if (force) state.refreshEpoch++;
    const epoch = state.refreshEpoch;
    // Dead-token recovery (the "frozen ads" 401 loop, 2026-06-11): a client
    // whose cached access token the server rejects used to 401 here every 60s
    // FOREVER — the catch below swallowed it, no new inventory ever arrived,
    // and every surface kept the last-baked creative. The fallback helper
    // applies the same conservative ladder as activation: signed-out → demo;
    // signed-in failure → ONE auth refresh (re-mint → real ads), demote to
    // demo only on an authoritative rejection (token cleared), and hold —
    // no demotion, no churn — on transient failures (offline/5xx).
    const r = await fetchPortfolioWithDemoFallback(
      deps.portfolio, deps.auth, deps.ccVersion,
      // Active campaign scopes the piggybacked kill verdict (parity with
      // the standalone /v1/killswitch poll's `campaign=` param).
      deps.activeAdRef.current?.campaignId || "");
    if (epoch !== state.refreshEpoch) {
      // The world changed while this fetch was in flight (forced swap or
      // sign-out clear): applying the response would reinstate stale ads.
      dlog("ext", "portfolio.refresh_stale",
        { epoch, current: state.refreshEpoch });
      return;
    }
    if (!r || r.ads.length === 0) return;
    const newSig = r.ads.map(a => a.adId).sort().join(",");
    const adsChanged = force || newSig !== state.lastAdSetSig;
    if (adsChanged) {
      state.adQueue = r.ads;
      state.rotationIdx = 0;
      // Latch the signature only when the apply actually went through: a
      // gated apply (kill/offline/disabled) must leave the sig unlatched so
      // the first healthy refresh re-applies even an UNCHANGED ad set —
      // otherwise recovery would serve the stale pre-kill creative until the
      // inventory happened to change.
      if (applyAd(state.adQueue[0], deps, state, force)) {
        state.lastAdSetSig = newSig;
      }
      dlog("ext", "portfolio.refresh", { adId: state.adQueue[0].adId, queueLen: state.adQueue.length, changed: true, forced: force });
      if (state.rotationTimer) clearInterval(state.rotationTimer);
      if (state.adQueue.length > 1) {
        state.rotationTimer = setInterval(() => rotateNext(deps, state), r.rotationIntervalMs);
        deps.timers.push(state.rotationTimer);
      }
    } else {
      // Same ad SET, but the server mints a FRESH session token on every fetch
      // (300s TTL). Adopt the new tokens so the in-use token never ages out and
      // starts 403-ing billable view events — WITHOUT re-patching the overlay
      // (adText/clickUrl/iconRef are unchanged, so there's no visible churn and
      // no reason to re-mint the loopback/corr). This closes the silent "billing
      // stops after ~5 min on stable inventory" bug: pre-fix the refreshed
      // response was discarded wholesale, so `activeAd.sessionToken` aged out.
      //
      // The `demo` stamp travels WITH the token: a mid-session demotion (token
      // death → this refresh comes from the DEMO portfolio) can return the same
      // adId set, and an adopted demo token on an object still marked demo:false
      // would keep the status bar showing ads while signed out — bypassing its
      // deliberate sign-in gate (statusBarAd keys on `!ad.demo`, audit BL-187).
      // Same in reverse: a re-auth must clear the stamp so real ads aren't
      // suppressed as demo.
      const freshByAdId = new Map(r.ads.map((a) => [a.adId, a]));
      state.adQueue = state.adQueue.map((a) => {
        const fresh = freshByAdId.get(a.adId);
        return fresh
          ? { ...a, sessionToken: fresh.sessionToken, demo: fresh.demo }
          : a;
      });
      const active = deps.activeAdRef.current;
      const freshActive = active ? freshByAdId.get(active.adId) : undefined;
      if (active && freshActive) {
        deps.activeAdRef.current = {
          ...active, sessionToken: freshActive.sessionToken,
          demo: freshActive.demo };
        if (deps.adRef.current && deps.adRef.current.adId === active.adId) {
          deps.adRef.current = {
            ...deps.adRef.current, sessionToken: freshActive.sessionToken,
            demo: freshActive.demo };
        }
      }
      dlog("ext", "portfolio.token_refreshed",
        { queueLen: r.ads.length, rotationIdx: state.rotationIdx, adId: active?.adId });
    }
  } catch { /* prime directive */ }
}

/** Sign-out teardown: drop every leftover REAL ad so no surface keeps serving
 *  — or billing — it signed-out. Empties the queue, disarms the rotation
 *  timer, nulls the shared ad refs (statusBarAd / reassert / the loopback's
 *  active-ad snapshot all key off them), and bumps the refresh epoch so an
 *  in-flight refresh can't re-apply a stale response. The 60s refresh interval
 *  stays armed on purpose: its next (demo or real) portfolio apply is what
 *  re-populates the queue and re-arms rotation. */
function clearAds(deps: AdRotationDeps, state: AdRotationState): void {
  state.refreshEpoch++;
  if (state.rotationTimer) {
    clearInterval(state.rotationTimer);
    state.rotationTimer = null;
  }
  state.adQueue = [];
  state.rotationIdx = 0;
  state.lastAdSetSig = "";
  deps.adRef.current = null;
  deps.activeAdRef.current = null;
  deps.session.set({ hasAd: false });
  dlog("ext", "rotation.cleared", {});
}

// Exactly one rotation subsystem exists per activation; setup replaces the
// hook. Module-scoped so the sign-out command (activation/commands.ts) can
// reach the live rotation state without threading the handle through
// activate() → registerCommands.
let liveClear: (() => void) | null = null;

/** Clear the live rotation's ad state on sign-out — see clearAds(). Safe
 *  no-op when rotation never set up (no ad at activation). Never throws. */
export function clearAdRotationOnSignOut(): void {
  try { liveClear?.(); } catch { /* best-effort */ }
}

/** The state object plus a `refreshNow` trigger. `refreshNow(force)` runs a
 *  portfolio refresh immediately (used by the sign-in handler to swap demo ads
 *  for real, user-crediting ones without waiting for the 60s timer). */
export type AdRotationHandle = AdRotationState & {
  refreshNow: (force?: boolean) => Promise<void>;
  /** Sign-out teardown — see clearAds(). */
  clear: () => void;
};

/** Set up the ad-rotation subsystem. Returns the live state augmented with a
 *  `refreshNow` trigger (timers are pushed into the shared `deps.timers`). */
export function setupAdRotation(
  deps: AdRotationDeps,
  portfolioResp: PortfolioResponse | null,
): AdRotationHandle {
  const state: AdRotationState = {
    adQueue: portfolioResp?.ads ?? [],
    rotationIdx: 0,
    rotationTimer: null,
    lastAdSetSig: (portfolioResp?.ads ?? []).map(a => a.adId).sort().join(","),
    refreshEpoch: 0,
  };
  const initialRotationMs = portfolioResp?.rotationIntervalMs ?? 120_000;
  if (state.adQueue.length > 1) {
    state.rotationTimer = setInterval(() => rotateNext(deps, state), initialRotationMs);
    deps.timers.push(state.rotationTimer);
    dlog("ext", "rotation.init", { intervalMs: initialRotationMs, queueLen: state.adQueue.length });
  }

  deps.timers.push(setInterval(() => void refreshPortfolio(deps, state), 60_000));
  // Augment the LIVE state object in place (don't copy — the timers mutate it).
  const handle = state as AdRotationHandle;
  handle.refreshNow = (force = false) => refreshPortfolio(deps, state, force);
  handle.clear = () => clearAds(deps, state);
  liveClear = handle.clear;
  return handle;
}