<?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 (+ present[])
 *   POST ?action=presence          {token}        -> "I am in the tower" heartbeat; returns present[]
 *   POST ?action=presence-end      {token}        -> "I have left the tower"; drops instantly
 *   POST ?action=assign            {token, principal, channel}   -> Geth watches that channel
 *   POST ?action=unassign          {token, principal, channel}   -> stop
 *
 * CHANNEL ASSIGNMENT (Ray 09-19 15:0x-16:1x): assignments are channels, held IN THE TOWER (one server
 * table), changed on the fly by whoever holds the watch; the Geth never picks. A Geth with channels is
 * MONITORING, with none is STANDING BY — both normal. The tap reads the table at send time, per event.
 * No minimum, no refusal, no fail branches (Ray: "keep it simple and less fallible").
 *
 * PRESENCE (added 2026-09-19, Ray's spec, locked in the 14:4x journal block): ONE combined list
 * of everyone IN THE TOWER, with an on_watch badge for the formal watch-standers. The list must
 * equal the control-room receive-set, so presence is recorded by wt-auth on EVERY authenticated
 * read (see wt_presence_touch) — the poll that receives a post is the act that marks you present.
 * The two actions here are the app's explicit heartbeat and clean exit; the poll is the mechanism.
 *
 * ⛔ 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';
require_once __DIR__ . '/wt-roster.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;

/**
 * A NEEDS_REAUTH session whose token has been dead this long can never resume (verifyToken refuses
 * an expired token outright), so it is retired to RELIEVED rather than left as a phantom. Measured
 * 2026-09-19: 6 such rows, heartbeats 2.7-7.2 days stale, rendering 3 principals as 5-6 seats.
 */
const LAPSED_RETIRE_SECONDS = 3600;

/** Cursor's automation cap per bot: "grok bot user limit 100 per hour" (its own 400 body, 2026-09-19). */

/**
 * Ray, 2026-09-19 18:0x: "Loom you are the first so you are by matter of Seniority the Watch Officer and
 * any other Geth that are assigned here will report to you." / 18:2x: "she can delegate watch channels
 * to others at will." ⇒ The Watch Officer may assign anyone; any OTHER Geth may assign only itself
 * (Solace measured a geth token reassigning a senior — a junior reassigning its own Watch Officer
 * would invert the chain quietly). Humans (Citadel) and senior watch tokens are unrestricted.
 */
const WT_WATCH_OFFICER = 'loom';

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;
}

/**
 * State transitions that must run BEFORE any read of the roster.
 *
 * ⛔ THE BURN-GAUGE REQUIREMENT LIVES HERE (moved out of watch-status 2026-09-19 so every reader
 * shares it). Ray's rule: expiry must never silently end a watch — it flips the post to visibly
 * NEEDS_REAUTH. 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.
 */
function sweepWatchSessions(PDO $pdo): void {
    // Token expiry flips ANY watch; heartbeat staleness flips only a POLL-mode principal's watch.
    // A push-mode seat entered once and is in until it leaves (see report-for-watch) — demanding
    // a heartbeat from it would reintroduce the per-host service Ray just told us to remove.
    $pdo->prepare("
        UPDATE wt_watch_sessions w
        LEFT JOIN wt_presence p ON p.principal = w.principal
        SET w.state='NEEDS_REAUTH'
        WHERE w.state='ACTIVE'
          AND (w.token_exp <= NOW()
               OR (COALESCE(p.mode,'poll') = 'poll' AND w.last_heartbeat < (NOW() - INTERVAL ? SECOND)))
    ")->execute([HEARTBEAT_STALE_SECONDS]);

    // Lapsed beyond recovery -> RELIEVED, with the reason written down so --list still explains
    // the row. This is the purge half of the live_watchers dedup (Ray's go, 2026-09-19).
    $pdo->prepare("
        UPDATE wt_watch_sessions
        SET state='RELIEVED', ended_at=NOW(),
            revoked_reason=COALESCE(revoked_reason, 'lapsed: token expired, never re-presented')
        WHERE state='NEEDS_REAUTH' AND token_exp < (NOW() - INTERVAL ? SECOND)
    ")->execute([LAPSED_RETIRE_SECONDS]);

    /**
     * ⛔ RAY'S RULING 2026-09-19 15:2x: "on watch and not here are conflicting. Not here is
     * indicated by your name not being in the list. You can't be on watch and not here at the
     * same time." ⇒ ABSENCE RELIEVES THE WATCH. A watch whose principal has no live presence
     * row (left, stale, or closed by a failed delivery) is RELIEVED here, with the reason, and
     * so drops out of the list entirely. The earlier "on_watch:true present:false, render red"
     * honesty row is RETIRED by this ruling — the list shows who is here, full stop.
     */
    $pdo->prepare("
        UPDATE wt_watch_sessions w
        LEFT JOIN wt_presence p
               ON p.principal = w.principal
              AND p.left_at IS NULL
              AND (p.mode = 'push' OR p.last_seen > (NOW() - INTERVAL ? SECOND))
        SET w.state='RELIEVED', w.ended_at=NOW(),
            w.revoked_reason=COALESCE(w.revoked_reason, 'left the tower (absence relieves the watch, Ray 09-19)')
        WHERE w.state IN ('ACTIVE','NEEDS_REAUTH') AND p.principal IS NULL
    ")->execute([WT_PRESENCE_STALE_SECONDS]);
}


// --------------------------------------------------------------------------- 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;
    }

    // live_watchers dedup, supersede half (Ray's go, 2026-09-19): a principal holds ONE watch.
    // Any other live row for the same principal is the previous token's session, superseded now.
    $pdo->prepare("
        UPDATE wt_watch_sessions
        SET state='RELIEVED', ended_at=NOW(),
            revoked_reason=COALESCE(revoked_reason, CONCAT('superseded by jti ', ?))
        WHERE principal=? AND jti<>? AND state IN ('ACTIVE','NEEDS_REAUTH')
    ")->execute([$jti, $principal, $jti]);

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

    // Reporting for watch is done FROM the tower: mark present with the session's name.
    wt_presence_touch($pdo, [
        'type' => 'watch', 'principal' => $principal, 'kind' => $kind,
        'jti' => $jti, 'display_name' => $displayName,
    ]);
    /**
     * ⭐ ENTERING IS ONE CALL, LIKE `rd join` (Ray 09-19: "like a lounge or a lab. If you are in you
     * are in and when gone the messages stop"; Solace 15:5x: "a terminal seat has no way to enter
     * without something running on its host"). A watch-token principal is a crew seat or a Geth,
     * never a browser: it is delivered to by PUSH, so its presence is mode=push — present from
     * this call until stand-down / presence-end / a failed delivery, with NO heartbeat required
     * and nothing installed on its host. Browsers (Citadel) stay mode=poll: their poll is their
     * presence. This is what makes the lounge rule hold for every kind of crew.
     */
    /**
     * ⛔ CORRECTED 16:3x (Solace's catch, on Ray's live test): a GETH has NO fleet listener, so
     * the room's push to fleet/agents/<geth> succeeds into nothing — mode=push would list it as
     * receiving forever with no failure to detect. ⇒ A Geth is push-mode ONLY once it has a
     * registered delivery ROUTE (its webhook, see register-route); until then it is poll-mode
     * and is in the room exactly while it is reading it. Crew seats (human/senior kinds) have a
     * fleet listener by construction and stay push-mode on entry.
     */
    if ($kind === 'geth') {
        $pdo->prepare("UPDATE wt_presence SET mode = IF(route IS NULL, 'poll', 'push') WHERE principal=?")->execute([$principal]);
    } else {
        $pdo->prepare("UPDATE wt_presence SET mode='push' WHERE principal=?")->execute([$principal]);
    }

    wt_publish_roster('on_watch', $principal);   // roster changed: a watch badge lit
    // 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']]);
    // Standing down leaves the tower too, with the reason on the row. verifyToken does not touch
    // presence (it is not wt_resolve_reader), so nothing re-registers behind this.
    $pdo->prepare("UPDATE wt_presence SET left_at=NOW(), absent_reason='stood_down', absent_detail=NULL WHERE principal=?")
        ->execute([(string)$claims['sub']]);
    // Dismissed => its chips go with it (Ray: "if they are not needed they get dismissed").
    $pdo->prepare("DELETE FROM wt_watch_assignment WHERE principal=?")->execute([(string)$claims['sub']]);
    wt_publish_roster('stood_down', (string)$claims['sub']);
    respond(200, ['status' => 'ok', 'stood_down' => $claims['sub']]);

case 'presence':
    // The app's explicit "I am in the tower" heartbeat. wt_require_reader has already touched
    // presence as a side effect of resolving the credential; this action exists so the client
    // has a named verb and learns the stale window from the server instead of guessing it.
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        respond(405, ['status' => 'error', 'error' => 'method_not_allowed']);
    }
    if (!empty($input['token']) && empty($_GET['token']) && empty($_SERVER['HTTP_AUTHORIZATION'])) {
        $_GET['token'] = (string)$input['token'];   // body token, same as the other POST actions
    }
    $reader = wt_require_reader($pdo, 'respond');
    sweepWatchSessions($pdo);
    respond(200, [
        'status'      => 'ok',
        'principal'   => $reader['principal'],
        'stale_after' => WT_PRESENCE_STALE_SECONDS,
        'present'     => presentList($pdo),
        'checked_at'  => gmdate('c'),
    ]);

case 'presence-end':
    // Clean exit: pagehide / logout. Without this the row ages out in WT_PRESENCE_STALE_SECONDS;
    // with it the drop is immediate. A still-open second tab will simply touch it live again.
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        respond(405, ['status' => 'error', 'error' => 'method_not_allowed']);
    }
    if (!empty($input['token']) && empty($_GET['token']) && empty($_SERVER['HTTP_AUTHORIZATION'])) {
        $_GET['token'] = (string)$input['token'];
    }
    $reader = wt_require_reader($pdo, 'respond');
    $pdo->prepare("UPDATE wt_presence SET left_at=NOW(), absent_reason='left', absent_detail=NULL WHERE principal=?")
        ->execute([$reader['principal']]);
    wt_publish_roster('left', $reader['principal']);
    respond(200, ['status' => 'ok', 'left' => $reader['principal'], 'checked_at' => gmdate('c')]);

case 'assign':
case 'unassign':
    // Whoever holds the watch may assign: a Citadel human in the tower (Ray), or a watch-token
    // principal with an ACTIVE watch (XO/seniors). The target must be a present principal.
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        respond(405, ['status' => 'error', 'error' => 'method_not_allowed']);
    }
    if (!empty($input['token']) && empty($_GET['token']) && empty($_SERVER['HTTP_AUTHORIZATION'])) {
        $_GET['token'] = (string)$input['token'];
    }
    $reader = wt_require_reader($pdo, 'respond');
    sweepWatchSessions($pdo);
    if ($reader['type'] === 'watch') {
        $st = $pdo->prepare("SELECT 1 FROM wt_watch_sessions WHERE principal=? AND state='ACTIVE'");
        $st->execute([$reader['principal']]);
        if (!$st->fetchColumn()) {
            respond(403, ['status' => 'error', 'error' => 'not_on_watch',
                          'message' => 'Assigning channels requires holding the watch.']);
        }
    }
    $target  = trim((string)($input['principal'] ?? ''));
    $channel = trim((string)($input['channel'] ?? ''));
    // Chain of command (see WT_WATCH_OFFICER): a Geth that is not the Watch Officer touches only itself.
    if ($reader['type'] === 'watch' && $reader['kind'] === 'geth'
        && $reader['principal'] !== WT_WATCH_OFFICER && $target !== $reader['principal']) {
        respond(403, ['status' => 'error', 'error' => 'not_watch_officer',
                      'message' => 'A Geth may assign or unassign only itself; the Watch Officer (' . WT_WATCH_OFFICER . ') delegates channels to others.']);
    }
    if ($target === '' || !in_array($channel, WT_CHANNELS, true)) {
        respond(400, ['status' => 'error', 'error' => 'bad_request', 'valid_channels' => WT_CHANNELS]);
    }
    $present = presentList($pdo);
    $isPresent = (bool)array_filter($present, fn($r) => $r['principal'] === $target);
    if ($action === 'assign') {
        if (!$isPresent) {
            respond(404, ['status' => 'error', 'error' => 'not_in_tower',
                          'message' => "{$target} is not in the tower; a channel can only be given to someone here."]);
        }
        $pdo->prepare("INSERT IGNORE INTO wt_watch_assignment (principal, channel, assigned_by) VALUES (?, ?, ?)")
            ->execute([$target, $channel, $reader['principal']]);
    } else {
        $pdo->prepare("DELETE FROM wt_watch_assignment WHERE principal=? AND channel=?")->execute([$target, $channel]);
    }
    wt_publish_roster($action, $target);   // chips changed
    $present = presentList($pdo);
    respond(200, [
        'status'     => 'ok',
        'action'     => $action,
        'principal'  => $target,
        'channel'    => $channel,
        'by'         => $reader['principal'],
        'present'    => $present,
        'channels'   => channelHolders($present),
        'checked_at' => gmdate('c'),
    ]);

case 'register-route':
    // Register (or clear) a Geth's delivery route: the URL the tower POSTs each room message and
    // assigned-channel signal to. Whoever holds the watch may set it (Ray via Citadel, XO via
    // watch token), or the Geth itself with its own token. Setting a route makes the Geth
    // push-mode: present until stand-down or a delivery fails. Clearing it (url:"") returns it
    // to poll-mode. The route is never echoed back in full.
    if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
        respond(405, ['status' => 'error', 'error' => 'method_not_allowed']);
    }
    if (!empty($input['token']) && empty($_GET['token']) && empty($_SERVER['HTTP_AUTHORIZATION'])) {
        $_GET['token'] = (string)$input['token'];
    }
    $reader = wt_require_reader($pdo, 'respond');
    $target = trim((string)($input['principal'] ?? $reader['principal']));
    $url    = trim((string)($input['url'] ?? ''));
    $auth   = trim((string)($input['auth'] ?? ''));   // optional bearer for the route; stored, never echoed
    if ($target !== $reader['principal'] && $reader['type'] === 'watch') {
        $st = $pdo->prepare("SELECT 1 FROM wt_watch_sessions WHERE principal=? AND state='ACTIVE'");
        $st->execute([$reader['principal']]);
        if (!$st->fetchColumn()) respond(403, ['status' => 'error', 'error' => 'not_on_watch']);
    }
    if ($url !== '' && !preg_match('#^https://[^\s]{8,500}$#', $url) && !preg_match('#^bridge:[a-z0-9-]{1,32}$#', $url)) {
        respond(400, ['status' => 'error', 'error' => 'bad_url', 'message' => 'route must be an https URL or bridge:<name>']);
    }
    $st = $pdo->prepare("SELECT principal_kind FROM wt_presence WHERE principal=?");
    $st->execute([$target]);
    if (!($k = $st->fetchColumn())) respond(404, ['status' => 'error', 'error' => 'not_in_tower']);
    // Registering a route is an ENTRY: reopen the row (a Geth that was out because it stopped
    // reading is now reachable by push), so the very first delivery is not skipped as "absent".
    $pdo->prepare("UPDATE wt_presence SET route=?, route_auth=?, mode=?, " . ($url !== '' ? "left_at=NULL, absent_reason=NULL, absent_detail=NULL, last_seen=NOW(), " : "") . "principal=principal WHERE principal=?")
        ->execute([$url !== '' ? $url : null, ($url !== '' && $auth !== '') ? $auth : null, ($url !== '' || $k !== 'geth') ? 'push' : 'poll', $target]);
    wt_publish_roster('route', $target);   // mode or reachability changed
    respond(200, ['status' => 'ok', 'principal' => $target, 'route_set' => $url !== '', 'auth_set' => $auth !== '',
                  'route_host' => $url !== '' ? parse_url($url, PHP_URL_HOST) : null,
                  'mode' => ($url !== '' || $k !== 'geth') ? 'push' : 'poll', 'by' => $reader['principal']]);

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.
     */
    sweepWatchSessions($pdo);

    $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,
        // PRESENCE (2026-09-19): ONE combined list, additive so existing clients keep working.
        'present'       => $present = presentList($pdo),
        'present_count' => count(array_filter($present, fn($r) => $r['present'])),
        // channel -> [present holders]; an empty list is an UNHELD channel (render it so)
        'channels'      => channelHolders($present),
        'stale_after'   => WT_PRESENCE_STALE_SECONDS,
        'checked_at'   => gmdate('c'),
    ]);

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