← back to Kickbacks Ai Upstream

src/portfolio/client.ts

234 lines

import { dlog } from "../log";
import { timeoutFetch } from "../util/http";

export interface PatchAd {
  adId: string; campaignId: string; adText: string;
  iconRef: string; iconUrl: string; clickUrl: string; bannerEnabled: boolean;
  sessionToken: string;
  /** True when this ad came from the signed-out DEMO portfolio. Demo ads
   *  render on every surface exactly like a real ad and their click opens the
   *  real advertiser URL, but their metrics route to /v1/metrics/demo (the
   *  advertiser is charged, no user is credited). Absent/false ⇒ a normal,
   *  user-crediting ad. */
  demo?: boolean;
}

export interface PortfolioBalances {
  lifetimeUsd: string;
  todayUsd: string;
  lastUpdatedMs: number;
}

export interface PortfolioResponse {
  ad: PatchAd | null;
  ads: PatchAd[];
  queueId: string;
  ttlMs: number;
  rotationIntervalMs: number;
  viewThresholdMs: number;
  balances: PortfolioBalances | null;
}

type Fetch = typeof fetch;

/** The slice of FleetSignals the wire clients feed (fleet-chattiness fix):
 *  raw piggybacked fields off a FRESH 2xx body. Implementations parse
 *  defensively and must never throw back into the fetch path. */
export interface FleetSignalSink {
  noteKill(raw: unknown): void;
  noteBalances(raw: unknown): void;
}

const DEFAULT_VIEW_THRESHOLD_MS = 3_000;

function safeHttpUrl(value: string): string {
  try {
    const u = new URL(value);
    return (u.protocol === "https:" || u.protocol === "http:") ? u.toString() : "";
  } catch {
    return "";
  }
}

/** Consumes S2's /v1/portfolio shape. W4-extended fields are optional so an
 *  older backend still works: queue_id, view_threshold_seconds, balances. */
export class PortfolioClient {
  private cache: { resp: PortfolioResponse; expiresAt: number } | null = null;
  private demoCache: { resp: PortfolioResponse; expiresAt: number } | null = null;
  constructor(private base: string, private token: () => string | null,
              private f: Fetch = timeoutFetch(15000),
              private signals: FleetSignalSink | null = null) {}

  async fetchAd(ccVersion: string): Promise<PatchAd | null> {
    const r = await this.fetchPortfolio(ccVersion);
    return r?.ad ?? null;
  }

  /** W4 queue-aware fetch. Returns the full response so callers can manage
   *  a local queue (drain to depth N, then refetch) and surface the server-
   *  authoritative balances + view threshold. `campaignId` (the active ad's
   *  campaign, optional) scopes the piggybacked kill verdict so campaign
   *  kills propagate exactly like the standalone /v1/killswitch poll. */
  async fetchPortfolio(ccVersion: string,
                       campaignId = ""): Promise<PortfolioResponse | null> {
    const url = `${this.base}/v1/portfolio?claude_code_version=${encodeURIComponent(ccVersion)}`
      + (campaignId ? `&campaign=${encodeURIComponent(campaignId)}` : "");
    return this._fetch(url, this.authHeaders(), false, () => this.cache,
      (c) => { this.cache = c; });
  }

  /** Signed-out PREVIEW fetch: hits the public /v1/portfolio/demo with NO auth
   *  header. Returns real engine ads (each stamped `demo: true`) whose session
   *  tokens are bound to the demo:<clientId> namespace — so the surfaces render
   *  exactly like the live product but metrics route to /v1/metrics/demo
   *  (advertiser charged, no user credit). `clientId` is the stable device id
   *  (auth.clientId()); without it the server returns an empty portfolio. */
  async fetchDemoPortfolio(ccVersion: string, clientId: string,
                           campaignId = ""): Promise<PortfolioResponse | null> {
    const url = `${this.base}/v1/portfolio/demo`
      + `?claude_code_version=${encodeURIComponent(ccVersion)}`
      + `&client_id=${encodeURIComponent(clientId)}`
      + (campaignId ? `&campaign=${encodeURIComponent(campaignId)}` : "");
    return this._fetch(url, {}, true, () => this.demoCache,
      (c) => { this.demoCache = c; });
  }

  private async _fetch(
    url: string, headers: Record<string, string>, demo: boolean,
    getCache: () => { resp: PortfolioResponse; expiresAt: number } | null,
    setCache: (c: { resp: PortfolioResponse; expiresAt: number }) => void,
  ): Promise<PortfolioResponse | null> {
    try {
      const r = await this.f(url, { headers });
      if (!r.ok) throw new Error(`portfolio ${r.status}`);
      const body = await r.json() as {
        ttl_seconds: number;
        view_threshold_seconds?: number;
        rotation_interval_seconds?: number;
        queue_id?: string;
        balances?: { lifetime_usd?: string; today_usd?: string;
                     last_updated_ms?: number };
        ads: { ad_id: string; campaign_id: string; title_text: string;
               icon_ref: string; icon_url?: string; click_url: string;
               banner_enabled?: boolean; session_token?: string }[];
      };
      // Fleet signals fire HERE — the fresh-2xx parse path — and nowhere
      // else. Critically NOT from the warm-cache fallback below (stale data
      // must never refresh a kill verdict's timestamp) and BEFORE any
      // caller-side early-return on empty inventory (a global kill empties
      // the ad list — exactly the moment the verdict matters most).
      this.signals?.noteKill((body as { kill?: unknown }).kill);
      if (!demo) this.signals?.noteBalances(
        (body as { balances?: unknown }).balances);
      const ads: PatchAd[] = (body.ads || []).map((a) => ({
        adId: a.ad_id, campaignId: a.campaign_id,
        adText: a.title_text,
        iconRef: a.icon_ref, iconUrl: a.icon_url || "", clickUrl: safeHttpUrl(a.click_url),
        bannerEnabled: a.banner_enabled === true,
        sessionToken: a.session_token || "",
        ...(demo ? { demo: true } : {}),
      }));
      const balances: PortfolioBalances | null = body.balances
        && typeof body.balances.lifetime_usd === "string"
        && typeof body.balances.today_usd === "string"
        ? { lifetimeUsd: body.balances.lifetime_usd,
            todayUsd: body.balances.today_usd,
            lastUpdatedMs: body.balances.last_updated_ms ?? Date.now() }
        : null;
      // Clamp server-supplied timing knobs (audit 2A-06). These drive
      // setInterval periods and, via applyPatch, full rewrites of CC's 4.9 MB
      // webview index.js. An out-of-range value (a bug or a hostile backend
      // sending rotation_interval_seconds: 0.001) would otherwise become a
      // per-millisecond disk-write loop on the host. viewThresholdMs is already
      // floored downstream; rotation + ttl were not.
      const ROTATION_FLOOR_MS = 15_000;          // never rewrite CC's file faster
      const TTL_CEIL_MS = 60 * 60_000;           // 1h cap on cache lifetime
      const rotationSec = body.rotation_interval_seconds;
      const rawRotationMs = rotationSec ? rotationSec * 1000 : 120_000;
      const rawTtlMs = (body.ttl_seconds || 0) * 1000;
      const resp: PortfolioResponse = {
        ad: ads[0] ?? null,
        ads,
        queueId: body.queue_id || "",
        ttlMs: Math.min(TTL_CEIL_MS, Math.max(0, rawTtlMs)),
        rotationIntervalMs: Math.max(ROTATION_FLOOR_MS, rawRotationMs),
        viewThresholdMs: (body.view_threshold_seconds
          ? body.view_threshold_seconds * 1000
          : DEFAULT_VIEW_THRESHOLD_MS),
        balances,
      };
      setCache({ resp, expiresAt: Date.now() + resp.ttlMs });
      return resp;
    } catch {
      const c = getCache();
      if (c && Date.now() < c.expiresAt) return c.resp;
      return null;
    }
  }

  private authHeaders(): Record<string, string> {
    const t = this.token();
    return t ? { authorization: `Bearer ${t}` } : {};
  }
}

/** The slice of AuthClient that demo-fallback resolution needs. */
export interface DemoFallbackAuth {
  accessToken(): string | null;
  clientId(): string;
  refresh(): Promise<boolean>;
}

/** Resolve the activation-time portfolio, falling back to the DEMO portfolio
 *  when the user is effectively signed out — INCLUDING the "token present but
 *  rejected" case. loadCached() trusts a cached access token without validating
 *  it, so a stale/expired token makes accessToken() truthy and the first real
 *  fetch 401s → no ad → dead surface (the user sees the sign-in prompt, never
 *  the demo preview). Here, when signed-in yields no ad we force one refresh:
 *  it either re-mints a valid token (→ real, user-crediting ads), clears the
 *  dead token on an explicit rejection (accessToken() → null → demo, so all the
 *  downstream token-based routing — metrics → /v1/metrics/demo, rotation → demo
 *  — lines up consistently), or fails TRANSIENTLY (offline/5xx: token kept,
 *  null returned, NO demo demotion — audit #10). A genuinely signed-out caller
 *  (no token) goes straight to demo and never triggers the refresh. Never
 *  throws beyond its callees. */
export async function fetchPortfolioWithDemoFallback(
  portfolio: PortfolioClient,
  auth: DemoFallbackAuth,
  ccVersion: string,
  campaignId = "",
): Promise<PortfolioResponse | null> {
  if (!auth.accessToken()) {
    return portfolio.fetchDemoPortfolio(ccVersion, auth.clientId(), campaignId);
  }
  // Signed in (token present). A NON-NULL response — including a valid-but-empty
  // portfolio (ads: []) — is authoritative; keep it. Empty inventory is normal
  // and must NOT trigger a refresh: a failed refresh can clear the token, so
  // doing it on every empty poll would needlessly sign a valid user out. Only a
  // HARD null (network failure / non-2xx like 401, with no warm cache) is
  // suspect — the cached access token may be dead (loadCached trusts it
  // unvalidated). Then force one refresh: re-mint (→ real ads) or clear
  // (→ accessToken() null → demo), which keeps every downstream token-based
  // surface aligned.
  const resp = await portfolio.fetchPortfolio(ccVersion, campaignId);
  if (resp) return resp;
  const refreshed = await auth.refresh();
  if (refreshed) {
    dlog("ext", "portfolio.demo_fallback", { reason: "token-revived" });
    return portfolio.fetchPortfolio(ccVersion, campaignId);
  }
  // Failed refresh: TRANSIENT vs FATAL (audit #10). refresh() keeps the
  // access token on transport failures (offline / timeout / 5xx) and clears
  // it only on an explicit server rejection. A still-present token means the
  // session may be perfectly valid (e.g. VS Code restored before Wi-Fi) —
  // do NOT demote to demo (demo metrics credit no user); serve nothing this
  // tick and let the caller's next refresh retry naturally. A cleared token
  // is authoritative: route to demo, aligned with every downstream surface.
  if (auth.accessToken()) {
    dlog("ext", "portfolio.demo_fallback", { reason: "transient-refresh" });
    return null;
  }
  dlog("ext", "portfolio.demo_fallback", { reason: "dead-token" });
  return portfolio.fetchDemoPortfolio(ccVersion, auth.clientId(), campaignId);
}