<?php
/**
 * watch-api.php — Watchtower watch-duty endpoint (the Geth door's backend)
 *
 * Ray's spec, Ready Room 2026-09-12: humans use the forward-facing Citadel login; Geth get
 * NO Citadel identity and come through a SEPARATE endpoint, presenting a JWT that a senior
 * minted out-of-band. They enter a name and report for watch duty. The watch takes more than
 * one login at once so a relief can report in before the outgoing officer leaves.
 *
 * ⛔ THE CENTRAL RULE, and the one this file exists to enforce:
 * THE TYPED NAME NEVER AUTHENTICATES ANYONE. It is a display label. The verified subject is
 * the token's `sub` claim. Solace's Geth door already refuses a name with no token client-side;
 * that is UI, not a control, so it is refused here again on the server where it counts.
 *
 * ⛔ THIS ENDPOINT CANNOT MINT. It holds the PUBLIC half of the RS256 keypair only. The private
 * key is root-owned at /var/lib/zora/watchtower-keys/ on Daystrom, outside every web docroot.
 * A total compromise of this app yields the ability to CHECK tokens, never to ISSUE them.
 * (On 2026-09-12 we found a key sitting in this very web account that granted unrestricted root
 * on two other fleet hosts. This split is that lesson applied, not decoration.)
 *
 * Actions:
 *   POST ?action=report-for-watch   {name, token}  -> open a watch session
 *   POST ?action=heartbeat          {token}        -> keep it live
 *   POST ?action=stand-down         {token}        -> end it cleanly (RELIEVED)
 *   GET  ?action=watch-status                      -> who holds the watch right now
 *
 * ⛔ AUTH: report-for-watch / heartbeat / stand-down require a WATCH TOKEN specifically — a Citadel
 * login cannot stand a watch. `watch-status` accepts EITHER credential but is no longer readable
 * unauthenticated: it used to serve the roster AND manned-state to anyone, which handed the public
 * internet an oracle for exactly when nobody was watching. See the note at that case.
 */

declare(strict_types=1);

header('Content-Type: application/json');

require_once '/var/www/data.finestoflines.net/citadel_2026/vendor/autoload.php';
require_once '/var/www/data.finestoflines.net/config/db-config.php';
require_once __DIR__ . '/wt-auth.php';

use Firebase\JWT\JWT;
use Firebase\JWT\Key;

const PUB_KEY_PATH = __DIR__ . '/watch-verify-key.pub.pem';
const ISSUER       = 'watchtower';
const AUDIENCE     = 'watch-officer';

/**
 * Per-source admission cap.
 *
 * ⛔ NOT PREMATURE. The fleet carries two live, overdue tickler items — stargate and
 * chaosactual sshd — whose shared defect is precisely "global admission budget, no per-source
 * cap", and the stargate one predicted a real outage by name before it happened. Shipping a new
 * forward-facing door with that same shape would be the third instance of a defect we have
 * already written down twice, this time self-inflicted. The cap is per-source by construction so
 * one noisy client cannot consume the budget for everyone.
 */
const RATE_WINDOW_SECONDS = 300;
const RATE_MAX_ATTEMPTS   = 10;

/** Heartbeat older than this and the post is not being stood, whatever the token says. */
const HEARTBEAT_STALE_SECONDS = 900;

function respond(int $status, array $body): never {
    http_response_code($status);
    echo json_encode($body, JSON_UNESCAPED_SLASHES);
    exit;
}

function db(): PDO {
    global $DB;
    $pdo = new PDO(
        "mysql:host={$DB['host']};dbname={$DB['pathfinder']};charset=utf8mb4",
        $DB['user'], $DB['pass']
    );
    $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
    return $pdo;
}

function sourceIp(): string {
    return $_SERVER['REMOTE_ADDR'] ?? 'unknown';
}

function logAttempt(PDO $pdo, string $outcome, ?string $detail = null): void {
    $pdo->prepare("INSERT INTO wt_login_attempts (source_ip, outcome, detail) VALUES (?, ?, ?)")
        ->execute([sourceIp(), $outcome, $detail]);
}

/** True when this source has burned its budget. Counts ALL outcomes, not just failures:
 *  a caller hammering the door with valid tokens is still hammering the door. */
function rateLimited(PDO $pdo): bool {
    $stmt = $pdo->prepare("
        SELECT COUNT(*) FROM wt_login_attempts
        WHERE source_ip = ? AND attempted_at > (NOW() - INTERVAL ? SECOND)
    ");
    $stmt->execute([sourceIp(), RATE_WINDOW_SECONDS]);
    return ((int)$stmt->fetchColumn()) >= RATE_MAX_ATTEMPTS;
}

/**
 * Verify a presented token all the way down, or fail closed.
 * Returns the decoded claims. Never returns on failure.
 */
function verifyToken(PDO $pdo, string $raw): array {
    if ($raw === '') {
        logAttempt($pdo, 'refused_malformed', 'empty token');
        respond(401, [
            'status' => 'error',
            'error'  => 'token_required',
            'message' => 'A watch token is required. A name alone cannot start a watch.',
        ]);
    }

    if (!is_readable(PUB_KEY_PATH)) {
        // Fail closed and say so. An endpoint that cannot verify must never fall through to
        // accepting — that is the failure mode where the door opens because the lock is broken.
        error_log('WATCH_API_FATAL: public key unreadable at ' . PUB_KEY_PATH);
        respond(503, ['status' => 'error', 'error' => 'verifier_unavailable']);
    }

    try {
        $claims = (array)JWT::decode($raw, new Key(file_get_contents(PUB_KEY_PATH), 'RS256'));
    } catch (\Firebase\JWT\ExpiredException $e) {
        logAttempt($pdo, 'refused_expired');
        respond(401, [
            'status' => 'error',
            'error'  => 'token_expired',
            'message' => 'This watch token has expired. Ask the XO to mint a fresh one.',
        ]);
    } catch (\Throwable $e) {
        // Bad signature, wrong algorithm, malformed — all one answer to the caller. The
        // distinction is useful to us, not to whoever is knocking.
        logAttempt($pdo, 'refused_bad_token', substr($e->getMessage(), 0, 200));
        respond(401, ['status' => 'error', 'error' => 'token_invalid']);
    }

    // ⛔ Signature alone is not enough. A correctly-signed token from the WRONG SYSTEM must
    // still be refused, which is the whole reason iss/aud are set and checked rather than
    // assumed. This is what keeps Citadel and Watchtower from silently accepting each other.
    if (($claims['iss'] ?? null) !== ISSUER || ($claims['aud'] ?? null) !== AUDIENCE) {
        logAttempt($pdo, 'refused_bad_token', 'iss/aud mismatch');
        respond(401, ['status' => 'error', 'error' => 'token_wrong_audience']);
    }

    $jti = (string)($claims['jti'] ?? '');
    $sub = (string)($claims['sub'] ?? '');
    if ($jti === '' || $sub === '') {
        logAttempt($pdo, 'refused_malformed', 'missing jti or sub');
        respond(401, ['status' => 'error', 'error' => 'token_invalid']);
    }

    // Early revocation. A JWT cannot be recalled on its own; this is what gives "senior revokes
    // a watch" any meaning at all.
    $stmt = $pdo->prepare("SELECT reason FROM wt_revoked_tokens WHERE jti = ?");
    $stmt->execute([$jti]);
    if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
        logAttempt($pdo, 'refused_revoked', $jti);
        respond(401, [
            'status'  => 'error',
            'error'   => 'token_revoked',
            'message' => 'This watch token was revoked' . ($row['reason'] ? ': ' . $row['reason'] : '.'),
        ]);
    }

    return $claims;
}

// --------------------------------------------------------------------------- dispatch

$action = $_GET['action'] ?? '';
$pdo    = db();

$input = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $raw   = file_get_contents('php://input');
    $input = json_decode($raw, true) ?: [];
}

switch ($action) {

case 'report-for-watch':
    if (rateLimited($pdo)) {
        logAttempt($pdo, 'refused_rate_limit');
        respond(429, [
            'status' => 'error',
            'error'  => 'rate_limited',
            'message' => 'Too many attempts from this source. Wait a few minutes.',
        ]);
    }

    $name   = trim((string)($input['name'] ?? ''));
    $token  = trim((string)($input['token'] ?? ''));
    $claims = verifyToken($pdo, $token);   // never returns on failure

    $principal = (string)$claims['sub'];
    $jti       = (string)$claims['jti'];
    // ⛔ Take the kind from the token, do not coerce it. The original form of this line was
    // `=== 'human' ? 'human' : 'geth'`, which silently relabelled ANY other value as geth —
    // so a senior standing a watch would have been displayed as a Geth. That is the exact
    // mislabel Ray corrected on 2026-09-12 ("make sure Z is tagged as senior or XO, not
    // GETH"), and a defaulting ternary is how it would have happened regardless of what the
    // minter wrote in the token. Unknown values fall back to geth only because the column is
    // an enum and an unknown string would be a write error, not because geth is a safe guess.
    $claimedKind = (string)($claims['kind'] ?? '');
    $kind = in_array($claimedKind, ['human', 'geth', 'senior'], true) ? $claimedKind : 'geth';
    $exp       = (int)$claims['exp'];

    // The name is recorded for the watch log so it reads properly. It is NOT consulted for
    // any access decision, here or anywhere downstream.
    $displayName = ($name !== '' && mb_strlen($name) <= 64) ? $name : null;

    // A token opens ONE session. Re-presenting it resumes that session rather than forking a
    // second one — a Geth cycles constantly and will re-report with the same token after a
    // restart, which is normal, not a new watch.
    $stmt = $pdo->prepare("SELECT id, state FROM wt_watch_sessions WHERE jti = ?");
    $stmt->execute([$jti]);
    $existing = $stmt->fetch(PDO::FETCH_ASSOC);

    if ($existing) {
        $pdo->prepare("
            UPDATE wt_watch_sessions
            SET state='ACTIVE', last_heartbeat=NOW(), display_name=COALESCE(?, display_name),
                source_ip=?, ended_at=NULL
            WHERE id=?
        ")->execute([$displayName, sourceIp(), $existing['id']]);
        $resumed = ($existing['state'] !== 'ACTIVE');
    } else {
        $pdo->prepare("
            INSERT INTO wt_watch_sessions
                (principal, display_name, principal_kind, jti, token_exp, state,
                 issued_by, source_ip)
            VALUES (?, ?, ?, ?, FROM_UNIXTIME(?), 'ACTIVE', 'unknown', ?)
        ")->execute([$principal, $displayName, $kind, $jti, $exp, sourceIp()]);
        $resumed = false;
    }

    logAttempt($pdo, 'accepted', $principal);

    // Report who else is on watch. Ray asked for overlap specifically so a relief can report
    // in before the outgoing officer leaves — so the arriving officer is told who they are
    // standing with, rather than having to go look.
    $others = $pdo->prepare("
        SELECT principal, display_name, principal_kind, principal_kind AS kind, state
        FROM wt_watch_sessions
        WHERE state IN ('ACTIVE','NEEDS_REAUTH') AND jti <> ?
    ");
    $others->execute([$jti]);

    respond(200, [
        'status'     => 'ok',
        'principal'  => $principal,
        'display_name' => $displayName,
        'kind'       => $kind,
        'jti'        => $jti,
        'expires_at' => gmdate('c', $exp),
        'resumed'    => $resumed,
        'on_watch_with' => $others->fetchAll(PDO::FETCH_ASSOC),
    ]);

case 'heartbeat':
    $claims = verifyToken($pdo, trim((string)($input['token'] ?? '')));
    $pdo->prepare("
        UPDATE wt_watch_sessions SET last_heartbeat=NOW(), state='ACTIVE'
        WHERE jti=? AND state IN ('ACTIVE','NEEDS_REAUTH')
    ")->execute([(string)$claims['jti']]);
    respond(200, ['status' => 'ok', 'principal' => $claims['sub']]);

case 'stand-down':
    $claims = verifyToken($pdo, trim((string)($input['token'] ?? '')));
    $pdo->prepare("
        UPDATE wt_watch_sessions SET state='RELIEVED', ended_at=NOW() WHERE jti=?
    ")->execute([(string)$claims['jti']]);
    respond(200, ['status' => 'ok', 'stood_down' => $claims['sub']]);

case 'watch-status':
    /**
     * ⛔ AUTH TIERING ADDED 2026-09-12 after Ray's screenshot showed the roster rendering while
     * the pane was still asking for a login — which made me check, and this endpoint was serving
     * EVERYTHING to anyone. feed-api and chat-api both require a watch token; this did not.
     *
     * The leak that matters is not the names. It is `manned`. An unauthenticated caller could
     * poll this from the public internet and learn EXACTLY WHEN NOBODY IS WATCHING — on the very
     * system whose job is to be watching. That is an availability oracle handed to whoever asks.
     *
     * ⇒ Tiered rather than closed, so the UI can still show a staffed/unstaffed badge before
     * login: WITHOUT a token you get the badge only; WITH one you get the roster. Names, roles,
     * heartbeats and token expiries are operational detail and now require standing a watch.
     * ⚠️ RESIDUAL, STATED NOT BURIED: the unauthenticated badge still reveals manned-vs-unmanned.
     * That is a deliberate trade for the pre-login UI and it is Ray's to overrule — say the word
     * and it requires a token outright.
     */
    // Citadel login OR watch token — a human who signed in the front door can see who is on
    // watch without having to stand one themselves. Same rule as the feed.
    $authed = wt_resolve_reader($pdo) !== null;

    /**
     * ⛔ THE BURN-GAUGE REQUIREMENT LIVES HERE.
     * Ray's rule: expiry must never silently end a watch — it flips the post to visibly
     * NEEDS_REAUTH. So this is a WRITE before it is a read: any session whose token has
     * expired, or whose heartbeat has gone stale, is transitioned before we report. If this
     * only read, an unmanned post would keep reporting as ACTIVE forever, which is exactly
     * the 57-hour failure (correct output, nobody watching) rebuilt in a new place.
     */
    $pdo->prepare("
        UPDATE wt_watch_sessions
        SET state='NEEDS_REAUTH'
        WHERE state='ACTIVE'
          AND (token_exp <= NOW() OR last_heartbeat < (NOW() - INTERVAL ? SECOND))
    ")->execute([HEARTBEAT_STALE_SECONDS]);

    $rows = $pdo->query("
        -- `kind` is aliased alongside `principal_kind` on purpose. report-for-watch returns
        -- `kind`; this returned only `principal_kind`, so a client reading `kind` here got
        -- undefined and its own default relabelled every row. Solace hit exactly that, and it
        -- mirrored my own coercion bug on the server — two plausible defaults, one on each side
        -- of the wire, both collapsing to 'geth', so Ray's XO renders as a Geth and NOTHING
        -- looks broken. A default that is plausible hides a mismatch. Emit both names rather
        -- than make the client compensate for my inconsistency.
        SELECT principal, display_name, principal_kind, principal_kind AS kind, state,
               reported_at, last_heartbeat, token_exp
        FROM wt_watch_sessions
        WHERE state IN ('ACTIVE','NEEDS_REAUTH')
        ORDER BY reported_at ASC
    ")->fetchAll(PDO::FETCH_ASSOC);

    $active = array_values(array_filter($rows, fn($r) => $r['state'] === 'ACTIVE'));

    $needsReauth = array_values(array_filter($rows, fn($r) => $r['state'] === 'NEEDS_REAUTH'));

    if (!$authed) {
        /**
         * ⛔ CLOSED OUTRIGHT 2026-09-12, second pass. The first pass tiered this — badge without
         * a token, roster with one — purely to keep Solace's pre-login drawer alive, and I
         * flagged the residual honestly: the badge still told an anonymous caller when the post
         * was unmanned.
         *
         * She then route-gated the UI so the drawer only draws under /layout, which her guard
         * already protects. ⇒ THE REASON FOR THE TRADE STOPPED EXISTING, so the trade goes too.
         * Measured before closing: no other consumer. `pf-watch-status` shares the name but
         * reads the MQTT topic fleet/watch/holder and never touches this endpoint — checked
         * rather than assumed, because same-name-different-subject has caught me twice today.
         *
         * ⭐ The lesson worth keeping over the code: a security compromise made for a caller's
         * convenience must be REVISITED when that caller changes, or it outlives its own
         * justification and nobody remembers it was ever a trade.
         */
        respond(401, [
            'status'  => 'error',
            'error'   => 'token_required',
            'message' => 'A watch token is required to read watch status.',
        ]);
    }

    respond(200, [
        'status'  => 'ok',
        'manned'  => count($active) > 0,
        'active_count' => count($active),
        'detail'  => 'full',
        // Reported separately and deliberately: a post with someone signed in whose token died
        // is NOT the same as a manned post, and must not be able to read as one.
        'needs_reauth' => $needsReauth,
        'on_watch'     => $active,
        'checked_at'   => gmdate('c'),
    ]);

default:
    respond(400, [
        'status' => 'error',
        'error'  => 'unknown_action',
        'actions' => ['report-for-watch', 'heartbeat', 'stand-down', 'watch-status'],
    ]);
}
