#!/usr/bin/env bash
# cf-token.sh - deterministic Cloudflare per-scope token resolver.
#
# Owns the cfat_/cfut_ prefix decision and the mint-or-reuse state machine that
# api.md "Token type - route endpoints by prefix" and "Provisioning and reusing
# a stable per-scope token" describe, so the agent never guesses a token
# endpoint. curl + jq only (the cloudflare skill binds API calls to curl/jq).
#
# Usage: cf-token.sh <scope> <secrets-file>
#   scope in {pages, d1, dns, access}
# Output: stdout = the secrets key name holding the active token (never a value).
#         stderr = a [cf-token] lifeline and, on failure, the endpoint + code.
# Exit:   0 when the scope token is active/reused and persisted; non-zero else.
set -u

API="https://api.cloudflare.com/client/v4"
BACKOFF="${CF_BACKOFF_SECONDS:-3}"
RETRIES="${CF_VERIFY_RETRIES:-10}"

die() { echo "[cf-token] error: $*" >&2; exit 1; }

scope="${1:-}"; secrets="${2:-}"
case "$scope" in pages|d1|storage|calendar-d1|dns|access) ;; *) die "unknown scope '${scope}'; use pages|d1|storage|calendar-d1|dns|access";; esac
[ -n "$secrets" ] && [ -r "$secrets" ] || die "secrets file not readable: '${secrets}'"

# House-credential fallback (Task 1662). After the 1631 storage-isolation
# remediation, a sub-account's cloudflare.env carries NO account-wide master; the
# sole master lives house-only at ${PLATFORM_ROOT}/config/cloudflare-house.env.
# The admin-run scopes (pages/dns/access) still mint from that master. When the
# passed file has no master and the house env is reachable AND carries one,
# resolve/mint/persist against the house env instead. src records which source
# was used, surfaced on every lifeline so an operator can see hosting is on the
# isolated path, not a stray per-account token. Single-tenant installs keep the
# master in the per-account file, so no fallback fires and behaviour is unchanged.
#
# The fallback is gated on the caller being the house account: the master is
# account-wide (D1+R2 Edit and API Tokens Write), so handing it to a client
# sub-account would defeat the 1631 storage boundary as well as leak Pages/DNS.
# A specialist spawn is NOT a trust tier - it runs under whichever account owns
# the session, and carries PLATFORM_ROOT exactly as an admin spawn does
# (pty-spawner.ts:2174), so spawn shape cannot stand in for caller identity.
#
# WHAT THIS GATE IS AND IS NOT. It closes the SANCTIONED path: the platform no
# longer mints an account-wide token on a sub-account's behalf through its own
# supported seam. It is NOT a defence against a compromised or prompt-injected
# specialist. Every account's session runs as the SAME OS user (no uid/gid is
# dropped at spawn) and the house env path is deterministic, so an agent holding
# Bash can read that file directly or export a false ACCOUNT_ID. Real enforcement
# needs OS user separation, which was costed and declined as disproportionate
# (Task 1690, archived undone). This is therefore permanently a boundary of
# intent, not of permission -- no follow-up tracks closing it. Do not cite this
# gate as proof a sub-account cannot reach the master.
#
# "House" is an exact shell mirror of resolveAccountDir + isHouseAccount
# (platform/services/claude-session-manager/src/config.ts:238-323) -- the same
# predicate the spawn path uses to decide HOUSE_ADMIN_SCOPE, so the gate and
# cross-account authority can never disagree about which account is the house.
# Its three arms: exactly one role:"house" account (the caller must be it), no
# house plus a single account (the caller is it), and anything else -- including
# two or more role:"house" accounts -- is corruption and denies rather than
# picking one silently. An account dir counts when it holds a parseable
# account.json; like resolveAccountDir, the UUID shape of the dir name is not
# re-validated here (provisioning enforces it).
file_has_master() { grep -Eq '^[[:space:]]*CLOUDFLARE_API_TOKEN=.+' "$1" 2>/dev/null; }

# deny_reason is deliberately global: the caller reads it for the lifeline.
# Every other variable is local, so this function cannot clobber the scope
# table's state (suffix/key/names/res/legacy/regexes/zone_scoped/mtag).
deny_reason=""
caller_is_house() {
  local adir cfg role houses cands d r
  deny_reason=""
  [ -n "${ACCOUNT_ID:-}" ] || { deny_reason="no-account-id"; return 1; }
  [ -n "${PLATFORM_ROOT:-}" ] || { deny_reason="no-platform-root"; return 1; }
  adir="$(dirname "$PLATFORM_ROOT")/data/accounts"
  cfg="${adir}/${ACCOUNT_ID}/account.json"
  [ -r "$cfg" ] || { deny_reason="no-account-json"; return 1; }
  role=$(jq -r '.role // empty' "$cfg" 2>/dev/null) || { deny_reason="unparseable-account-json"; return 1; }
  houses=0; cands=0
  for d in "$adir"/*/; do
    [ -r "${d}account.json" ] || continue
    cands=$((cands+1))
    r=$(jq -r '.role // empty' "${d}account.json" 2>/dev/null) || r=""
    [ "$r" = "house" ] && houses=$((houses+1))
  done
  if [ "$houses" -gt 1 ]; then deny_reason="multiple-house-accounts"; return 1; fi
  if [ "$houses" -eq 1 ] && [ "$role" = "house" ]; then return 0; fi
  if [ "$houses" -eq 0 ] && [ "$cands" -eq 1 ]; then return 0; fi
  deny_reason="not-house"
  return 1
}

src=account
if ! file_has_master "$secrets"; then
  house="${CF_HOUSE_ENV:-}"
  if [ -z "$house" ] && [ -n "${PLATFORM_ROOT:-}" ]; then
    house="${PLATFORM_ROOT}/config/cloudflare-house.env"
  fi
  if [ -n "$house" ] && [ -r "$house" ] && file_has_master "$house"; then
    if ! caller_is_house; then
      echo "[cf-token] op=resolve scope=${scope} master=house action=deny result=error code=0 src=house caller=${ACCOUNT_ID:-unset} reason=${deny_reason}" >&2
      die "account '${ACCOUNT_ID:-unset}' is not the house account, so it cannot mint the '${scope}' scope from the account-wide Cloudflare master, which is house-only. Run this from the house admin session."
    fi
    secrets="$house"; src=house
  else
    echo "[cf-token] op=resolve scope=${scope} master=none action=resolve result=error code=0 src=account" >&2
    die "no Cloudflare master token: '${secrets}' carries none and no house credential is reachable (set CF_HOUSE_ENV or PLATFORM_ROOT)"
  fi
fi

# Load credentials (api.md load-credentials pre-flight). :? aborts if unset/empty,
# so a credential-unloaded call can never reach the API.
set -a; . "$secrets"; set +a
: "${CLOUDFLARE_API_TOKEN:?credentials not loaded - source the secrets file before any call}"
: "${CLOUDFLARE_ACCOUNT_ID:?account id not loaded - source the secrets file before any call}"
ACC="$CLOUDFLARE_ACCOUNT_ID"

# Brand for the minted token name: CF_TOKEN_BRAND overrides; else the <brand>-code
# path segment of the secrets file; else fail closed (no guessed name).
brand="${CF_TOKEN_BRAND:-}"
if [ -z "$brand" ]; then
  case "$secrets" in
    */*-code/*) brand="$(printf '%s\n' "$secrets" | tr '/' '\n' | grep -E -- '-code$' | head -1)";;
  esac
fi
[ -n "$brand" ] || die "cannot derive brand from path; set CF_TOKEN_BRAND"

# Scope table - resolved by case, never inferred.
# pages/d1/dns select their permission group by EXACT name (a broad substring once
# picked "Access: Custom Pages Write" over "Pages Write"); access keeps a regex
# because it legitimately enumerates duplicate-named candidates and functional-probes.
legacy=""; names=""; regexes=""; zone_scoped=""
case "$scope" in
  pages|d1) suffix="pages-d1"; key="CF_PAGES_D1_TOKEN"; legacy="CF_PAGES_TOKEN"
            # exact permission-group names separated by ';'
            names="Pages Write;D1 Write"; res='{"com.cloudflare.api.account.'"$ACC"'":"*"}';;
  storage)  suffix="storage"; key="CF_STORAGE_TOKEN"
            # Task 1670 — the storage broker mints one data token from the house
            # minter carrying both D1 and R2 write. Account-scoped like pages/d1.
            names="D1 Write;Workers R2 Storage Write"; res='{"com.cloudflare.api.account.'"$ACC"'":"*"}';;
  calendar-d1) suffix="calendar-d1"; key="CF_CALENDAR_D1_TOKEN"
            # Task 1670 — the booking calendar mints a D1-only token from the
            # house minter (no R2; least privilege for reconcile/publish).
            names="D1 Write"; res='{"com.cloudflare.api.account.'"$ACC"'":"*"}';;
  dns)      suffix="dns"; key="CF_DNS_TOKEN"
            # zone-scoped: res is set after the master-prefix routing (nesting differs)
            names="DNS Write"; zone_scoped=1;;
  access)   suffix="access"; key="CF_ACCESS_TOKEN"
            regexes="access.*apps.*polic.*(write|edit)"; res='{"com.cloudflare.api.account.'"$ACC"'":"*"}';;
esac
name="${brand}-${suffix}"

read_key() { grep -m1 "^$1=" "$secrets" 2>/dev/null | cut -d= -f2-; }
prefix_tag() { case "$CLOUDFLARE_API_TOKEN" in cfat_*) echo cfat;; cfut_*) echo cfut;; *) echo other;; esac; }

# --- Reuse path: a persisted token is already active (api.md). No verify, no mint.
existing="$(read_key "$key")"; used_key="$key"
if [ -z "$existing" ] && [ -n "$legacy" ]; then
  existing="$(read_key "$legacy")"; [ -n "$existing" ] && used_key="$legacy"
fi
if [ -n "$existing" ]; then
  echo "[cf-token] op=resolve scope=${scope} master=$(prefix_tag) action=reuse result=active code=0 src=${src}" >&2
  printf '%s\n' "$used_key"; exit 0
fi

# --- Mint path: route strictly by master prefix. Any other prefix fails closed.
case "$CLOUDFLARE_API_TOKEN" in
  cfat_*) base="$API/accounts/$ACC"; mtag=cfat;;
  cfut_*) base="$API/user"; mtag=cfut;;
  *) echo "[cf-token] op=resolve scope=${scope} master=other action=mint result=error code=0 src=${src}" >&2
     die "unrecognised master token prefix (not cfat_/cfut_); refusing to guess an endpoint";;
esac

# Zone-scoped resource shape branches by master type. An account-owned (cfat_) master
# rejects a flat zone.* resource with code 1001 "Must specify a zone for account owned
# tokens, or nest zone under specific account resource"; nest it under the account.
# A user-owned (cfut_) master accepts the flat zone.* shape.
if [ -n "$zone_scoped" ]; then
  case "$mtag" in
    cfat) res='{"com.cloudflare.api.account.'"$ACC"'":{"com.cloudflare.api.account.zone.*":"*"}}';;
    cfut) res='{"com.cloudflare.api.account.zone.*":"*"}';;
  esac
fi

api_get() { curl -sS -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" "$1"; }

# Resolve permission-group id(s): access matches by regex, pages/d1/dns by exact name.
pg_json="$(api_get "${base}/tokens/permission_groups")"
echo "$pg_json" | jq -e '.success==true' >/dev/null 2>&1 || {
  code=$(echo "$pg_json" | jq -r '.errors[0].code // "?"')
  echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${code} src=${src}" >&2
  die "permission_groups failed at ${base}/tokens/permission_groups code=${code}"
}

collect_ids() { echo "$pg_json" | jq -r --arg re "$1" '.result[] | select(.name|test($re;"i")) | .id'; }
collect_exact() { echo "$pg_json" | jq -r --arg n "$1" '.result[] | select(.name==$n) | .id'; }

mint_with() { # $1 = json permission_groups array -> echoes minted token value or empty
  local body
  body=$(jq -cn --arg n "$name" --argjson res "$res" --argjson pg "$1" \
    '{name:$n, policies:[{effect:"allow", resources:$res, permission_groups:$pg}]}')
  curl -sS -X POST -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
    -H "Content-Type: application/json" "${base}/tokens" --data "$body" \
    | jq -r '.result.value // empty'
}

verify_token() { # $1 token -> echoes "active" or the failing code; retries transient 10000
  local i s
  s=""
  for i in $(seq 1 "$RETRIES"); do
    s=$(curl -sS -H "Authorization: Bearer $1" "${base}/tokens/verify" \
          | jq -r '.result.status // (.errors[0].code | tostring) // "error"')
    [ "$s" = "active" ] && { echo active; return 0; }
    [ "$s" = "10000" ] && { sleep "$BACKOFF"; continue; }
    echo "$s"; return 1
  done
  echo "$s"; return 1
}

finish() { # $1 token value -> persist under canonical key, report, exit 0
  ( umask 077; printf '%s=%s\n' "$key" "$1" >> "$secrets" )
  echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=active code=0 src=${src}" >&2
  printf '%s\n' "$key"; exit 0
}

if [ "$scope" = "access" ]; then
  # The "Access: Apps and Policies Write" group can resolve to more than one id;
  # the wrong one yields a token that returns 10000 on every call, reads included.
  # Try each candidate, mint+verify, then a functional read; keep the one that works.
  candidates="$(collect_ids "$regexes")"
  [ -n "$candidates" ] || {
    echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=0 src=${src}" >&2
    die "no permission group matched /${regexes}/"; }
  for cid in $candidates; do
    tok=$(mint_with "$(jq -cn --arg id "$cid" '[{id:$id}]')")
    [ -n "$tok" ] || continue
    st=$(verify_token "$tok"); [ "$st" = "active" ] || continue
    fn=$(curl -sS -H "Authorization: Bearer $tok" "${base}/access/apps" \
           | jq -r '.success // (.errors[0].code|tostring)')
    [ "$fn" = "true" ] && finish "$tok"
    # else poison id - try the next candidate
  done
  echo "[cf-token] op=resolve scope=access master=${mtag} action=mint result=error code=10000 src=${src}" >&2
  die "every Access permission-group candidate returned 10000 on a functional read (poison id)"
else
  pg_array='[]'
  OLDIFS=$IFS; IFS=';'
  for nm in $names; do
    id=$(collect_exact "$nm" | head -1)
    [ -n "$id" ] || {
      IFS=$OLDIFS
      echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0 src=${src}" >&2
      die "no permission group named '${nm}'"; }
    pg_array=$(echo "$pg_array" | jq -c --arg id "$id" '. + [{id:$id}]')
  done
  IFS=$OLDIFS
  tok=$(mint_with "$pg_array")
  [ -n "$tok" ] || {
    echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=0 src=${src}" >&2
    die "mint returned no token value at ${base}/tokens"; }
  st=$(verify_token "$tok")
  [ "$st" = "active" ] || {
    echo "[cf-token] op=resolve scope=${scope} master=${mtag} action=mint result=error code=${st} src=${src}" >&2
    die "verify failed code=${st} at ${base}/tokens/verify"; }
  finish "$tok"
fi
