diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 1c82cb05da..2087216056 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -178,6 +178,11 @@ jobs: # only our own /live/ is framed. Pure, no browser. - name: The BROWSER view run: npm run check:queen-browser + # The runner cabinet: the session token rides only in a header with + # credentials omitted, a runner token is shown once and kept nowhere, and + # the page has no field for a provider key. Pure, no browser. + - name: The runner cabinet + run: npm run check:queen-runners # What that identity is FOR. The owner's rule, 2026-09-20: only registered # people write to the Queen. The gate that holds it is the proxy's, not # this bundle's -- a rule shipped in a public bundle is a suggestion -- and diff --git a/apps/website/package.json b/apps/website/package.json index 281c669437..d77dda67ab 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -83,6 +83,7 @@ "check:queen-redirect": "node --experimental-strip-types qa/queen-redirect-contract.mjs", "check:app-session-identity": "node --experimental-strip-types qa/app-session-identity-contract.mjs", "check:queen-browser": "node --experimental-strip-types qa/queen-browser-contract.mjs", + "check:queen-runners": "node --experimental-strip-types qa/queen-runners-contract.mjs", "check:queen-chat-gate": "node --experimental-strip-types qa/queen-chat-gate-contract.mjs", "check:queen-chat-tabs": "node --experimental-strip-types qa/queen-chat-tabs-contract.mjs", "check:queen-contrast": "node --experimental-strip-types qa/queen-contrast-contract.mjs", diff --git a/apps/website/qa/queen-contrast-contract.mjs b/apps/website/qa/queen-contrast-contract.mjs index 14b93c4eda..62a958c522 100644 --- a/apps/website/qa/queen-contrast-contract.mjs +++ b/apps/website/qa/queen-contrast-contract.mjs @@ -222,6 +222,9 @@ const REACHED = { /* PEOPLE, the contributors half of that tab: the fourth sheet the list has caught on its first run, which is four for four. */ 'src/components/QueenPeople.css', + /* MY RUNNERS, the cabinet inside that tab: a veil over the hive, blurred, + like the leaderboard rows it sits beside. */ + 'src/components/QueenRunners.css', /* LEVEL II, the comb on the ROADMAP view: five for five. It renders inside .rm, whose opaque gradient already grounds it, and its own panel is opaque on top of that. */ diff --git a/apps/website/qa/queen-runners-contract.mjs b/apps/website/qa/queen-runners-contract.mjs new file mode 100644 index 0000000000..521baabc63 --- /dev/null +++ b/apps/website/qa/queen-runners-contract.mjs @@ -0,0 +1,148 @@ +// MY RUNNERS: the cabinet that mints runner tokens for the person signed in. +// +// Calls the decisions in src/lib/queenRunners.ts with real inputs and a fetch +// that records what it was asked. Each rule below is one a wrong edit would +// break quietly: a session token sent with cookies or an extra header, a +// runner token kept past the one answer that carries it, a provider key field +// slipping into a page that promises it has none. +// +// node --experimental-strip-types qa/queen-runners-contract.mjs + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { + CABINET_HOME, + CABINET_PATH, + RUNNER_TOKEN, + cabinetOf, + callRunners, + runnerOf, + setupLines, +} from '../src/lib/queenRunners.ts' + +const BASE = 'https://queen.invalid/' +const TOKEN = `qr_${'A'.repeat(43)}` +const RUNNER = { + id: 3, + label: 'laptop', + lane: 100000003, + tokenHint: 'AAAA', + createdAt: '2026-10-01T00:00:00Z', + lastSeenAt: null, + state: 'never-seen', +} + +/** A fetch that answers from a script and records every request. */ +function recorder(script) { + const asked = [] + const fetch = async (url, init) => { + asked.push({ url, ...init }) + const next = script.shift() + assert.ok(next, `unexpected request ${init.method} ${url}`) + return { ok: next.status >= 200 && next.status < 300, status: next.status, json: async () => next.body ?? {} } + } + return { asked, fetch } +} + +const env = (fetch, token = 'session-token') => ({ base: BASE, fetch, token: () => token }) + +// 1. Nobody signed in: no request at all. +{ + const { asked, fetch } = recorder([]) + assert.deepEqual(await callRunners(env(fetch, null), { kind: 'list' }), { state: 'signin' }) + assert.equal(asked.length, 0) +} + +// 2. Every request: credentials omitted, exactly two headers, bearer is the session. +{ + const { asked, fetch } = recorder([{ status: 200, body: { runners: [RUNNER], limit: 5 } }]) + const view = await callRunners(env(fetch), { kind: 'list' }) + assert.equal(view.state, 'ready') + assert.equal(view.cabinet.runners[0].label, 'laptop') + const [req] = asked + assert.equal(req.url, `https://queen.invalid${CABINET_PATH}`) + assert.equal(req.credentials, 'omit') + assert.deepEqual(Object.keys(req.headers).sort(), ['Authorization', 'Content-Type']) + assert.equal(req.headers.Authorization, 'Bearer session-token') +} + +// 3. A refused session is "sign in", a server failure is "unavailable". +{ + const a = recorder([{ status: 401 }]) + assert.deepEqual(await callRunners(env(a.fetch), { kind: 'list' }), { state: 'signin' }) + const b = recorder([{ status: 503 }]) + assert.deepEqual(await callRunners(env(b.fetch), { kind: 'list' }), { state: 'unavailable' }) +} + +// 4. Create: the token is shown once, only in `minted`, and only when it is a +// runner token as minted; the list is re-read after. +{ + const { asked, fetch } = recorder([ + { status: 201, body: { runner: RUNNER, token: TOKEN } }, + { status: 200, body: { runners: [RUNNER], limit: 5 } }, + ]) + const view = await callRunners(env(fetch), { kind: 'create', label: ' my laptop ' }) + assert.equal(view.state, 'minted') + assert.equal(view.token, TOKEN) + assert.equal(JSON.parse(asked[0].body).label, 'my laptop') + assert.equal(asked[1].method, 'GET') + + const bad = recorder([{ status: 201, body: { runner: RUNNER, token: 'sk-live-something' } }]) + assert.deepEqual(await callRunners(env(bad.fetch), { kind: 'create', label: 'x' }), { state: 'unavailable' }) +} + +// 5. Refusals keep the list on screen and send nothing when there is no name. +{ + const kept = { runners: [runnerOf(RUNNER)], limit: 5 } + const empty = recorder([]) + const noName = await callRunners(env(empty.fetch), { kind: 'create', label: ' ' }, kept) + assert.deepEqual(noName, { state: 'refused', cabinet: kept, reason: 'label' }) + assert.equal(empty.asked.length, 0) + const full = recorder([{ status: 409 }]) + const limit = await callRunners(env(full.fetch), { kind: 'create', label: 'one more' }, kept) + assert.deepEqual(limit, { state: 'refused', cabinet: kept, reason: 'limit' }) +} + +// 6. Revoke: DELETE by id, then the list; a nonsense id never reaches the wire. +{ + const { asked, fetch } = recorder([{ status: 204 }, { status: 200, body: { runners: [], limit: 5 } }]) + const view = await callRunners(env(fetch), { kind: 'revoke', id: 3 }) + assert.equal(view.state, 'ready') + assert.equal(asked[0].method, 'DELETE') + assert.equal(asked[0].url, `https://queen.invalid${CABINET_PATH}/3`) + const n = recorder([{ status: 200, body: { runners: [], limit: 5 } }]) + await callRunners(env(n.fetch), { kind: 'revoke', id: -1 }) + assert.equal(n.asked.length, 1) + assert.equal(n.asked[0].method, 'GET') +} + +// 7. The wire is data: malformed runners are dropped, labels are capped. +{ + assert.equal(runnerOf({ ...RUNNER, state: 'pwned' }), null) + assert.equal(runnerOf({ ...RUNNER, tokenHint: '' }), null) + assert.equal(runnerOf({ ...RUNNER, label: 'x'.repeat(200) }).label.length, 40) + assert.deepEqual(cabinetOf({ runners: [RUNNER, null, 7], limit: 'x' }).runners.length, 1) + assert.equal(cabinetOf(null).limit, 5) + assert.ok(RUNNER_TOKEN.test(TOKEN)) +} + +// 8. The setup lines name the provider key nowhere; they carry the runner +// token and the Queen's address and nothing else. +{ + const lines = setupLines(TOKEN, 'https://queen.invalid').join('\n') + assert.ok(lines.includes(TOKEN)) + assert.ok(!/API_KEY|sk-|provider/i.test(lines)) + assert.ok(CABINET_HOME.startsWith('https://app.t27.ai/queen/')) +} + +// 9. The page has no field for a provider key and never stores the token. +{ + const page = readFileSync(new URL('../src/components/QueenRunners.tsx', import.meta.url), 'utf8') + const lib = readFileSync(new URL('../src/lib/queenRunners.ts', import.meta.url), 'utf8') + for (const forbidden of ['localStorage', 'sessionStorage', 'document.cookie', 'type="password"', 'apiKey', 'api_key']) { + assert.ok(!page.includes(forbidden) && !lib.includes(forbidden), `runners cabinet must not use ${forbidden}`) + } + assert.ok(!lib.includes("credentials: 'include'")) +} + +console.log('queen-runners contract: ok') diff --git a/apps/website/src/components/QueenLeaderboard.tsx b/apps/website/src/components/QueenLeaderboard.tsx index 05674d3d32..f8b5082d43 100644 --- a/apps/website/src/components/QueenLeaderboard.tsx +++ b/apps/website/src/components/QueenLeaderboard.tsx @@ -14,6 +14,7 @@ import { useEffect, useState } from 'react' import { QUEEN_API } from '../lib/queenApi' import QueenPeople from './QueenPeople' +import QueenRunners from './QueenRunners' import './QueenLeaderboard.css' interface Contributor { @@ -21,6 +22,8 @@ interface Contributor { claimed: boolean /** Their GitHub login, when the operator signed the lane as `@login`. */ github?: string + /** The lanes ran on the lender's own machine: a runner, named by Telegram. */ + runner?: boolean keys: number[] accepted: number /** Accepted issues whose boundary named a .t27 file: the game's own goal. */ @@ -163,6 +166,10 @@ export default function QueenLeaderboard({ lang }: { lang: 'en' | 'ru' }) { open repositories. */} + {/* The door this tab used to only point at: lend a lane by running it + yourself, with your key on your own machine. */} + +

{c.lanesTitle}

{c.lanesLead}

@@ -173,7 +180,7 @@ export default function QueenLeaderboard({ lang }: { lang: 'en' | 'ru' }) { {board.contributors.map((row, i) => { const login = loginOf(row) return ( -
  • +
  • {i + 1} {/* The avatar comes from github.com/.png, a public redirect: no API call, no token, and a leaderboard that does diff --git a/apps/website/src/components/QueenRunners.css b/apps/website/src/components/QueenRunners.css new file mode 100644 index 0000000000..ee7ef24569 --- /dev/null +++ b/apps/website/src/components/QueenRunners.css @@ -0,0 +1,133 @@ +/* MY RUNNERS: same opaque/blurred grounds as the leaderboard rows, because the + panel carries text over the lit hive (qa/queen-contrast-contract.mjs). */ +.qr { + margin: 18px 0 8px; + padding: 14px 16px; + border: 1px solid var(--hud-line, rgba(0, 255, 136, 0.22)); + border-radius: 10px; + background: var(--hud-veil, rgba(2, 8, 6, 0.72)); + -webkit-backdrop-filter: blur(6px); + backdrop-filter: blur(6px); +} +.qr-head h3 { + margin: 0 0 6px; + font-size: 15px; + letter-spacing: 0.08em; + color: var(--hud-green, #00ff88); +} +.qr-head p, +.qr-note, +.qr-small { + margin: 0 0 6px; + line-height: 1.5; + color: var(--hud-muted, rgba(255, 255, 255, 0.78)); +} +.qr-key { + color: var(--hud-gold, #ffd700) !important; + font-size: 13px; +} +.qr-small { + font-size: 12px; +} +.qr-note a { + color: var(--hud-green, #00ff88); +} +.qr-list { + list-style: none; + margin: 10px 0; + padding: 0; + display: grid; + gap: 6px; +} +.qr-row { + display: grid; + grid-template-columns: 10px minmax(0, 1fr) auto; + grid-template-areas: 'dot label button' 'dot meta button'; + align-items: center; + column-gap: 10px; + padding: 8px 10px; + border: 1px solid var(--hud-line, rgba(0, 255, 136, 0.22)); + border-radius: 8px; + background: #020806; +} +.qr-dot { + grid-area: dot; + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.35); +} +.qr-row.is-online .qr-dot { + background: var(--hud-green, #00ff88); +} +.qr-label { + grid-area: label; + overflow-wrap: anywhere; + color: #e8f5ee; +} +.qr-meta { + grid-area: meta; + font-size: 12px; + color: var(--hud-muted, rgba(255, 255, 255, 0.78)); + overflow-wrap: anywhere; +} +.qr-row button { + grid-area: button; +} +.qr button { + padding: 6px 10px; + border: 1px solid var(--hud-line, rgba(0, 255, 136, 0.22)); + border-radius: 6px; + background: #04170e; + color: var(--hud-green, #00ff88); + font: inherit; + font-size: 13px; + cursor: pointer; +} +.qr button:disabled { + opacity: 0.5; + cursor: default; +} +.qr-form { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 8px 0 4px; +} +.qr-form input { + flex: 1 1 180px; + min-width: 0; + padding: 6px 10px; + border: 1px solid var(--hud-line, rgba(0, 255, 136, 0.22)); + border-radius: 6px; + background: #000; + color: #e8f5ee; + font: inherit; +} +.qr-minted { + margin: 10px 0; + padding: 10px 12px; + border: 1px solid var(--hud-gold, #ffd700); + border-radius: 8px; + background: #141000; +} +.qr-minted p { + margin: 4px 0 8px; + color: var(--hud-muted, rgba(255, 255, 255, 0.78)); +} +.qr-setup { + margin: 0 0 8px; + padding: 8px; + max-width: 100%; + overflow-x: auto; + white-space: pre; + font-family: 'JetBrains Mono', ui-monospace, monospace; + font-size: 12px; + color: #e8f5ee; + background: #000; + border-radius: 6px; +} +.qr-actions { + display: flex; + gap: 8px; +} diff --git a/apps/website/src/components/QueenRunners.tsx b/apps/website/src/components/QueenRunners.tsx new file mode 100644 index 0000000000..6d0f9f5cc9 --- /dev/null +++ b/apps/website/src/components/QueenRunners.tsx @@ -0,0 +1,264 @@ +// MY RUNNERS: mint, list and revoke the runner tokens of the person signed in +// on app.t27.ai. Decisions live in lib/queenRunners.ts; this file only draws +// them. There is no field for a provider key here and there never will be: the +// key stays on the runner's machine, which is the whole reason a runner exists. +import { useCallback, useEffect, useState } from 'react' +import { appSessionFromWindow } from '../lib/appSessionIdentity' +import { QUEEN_API } from '../lib/queenApi' +import { + CABINET_HOME, + type CabinetView, + callRunners, + type RunnersCall, + type RunnersEnv, + setupLines, +} from '../lib/queenRunners' +import './QueenRunners.css' + +interface RunnersCopy { + title: string + lead: string + keyStays: string + signin: string + elsewhere: string + unavailable: string + loading: string + none: string + namePlaceholder: string + create: string + revoke: string + revokeConfirm: (label: string) => string + lane: string + state: Record<'never-seen' | 'online' | 'offline', string> + limit: (n: number) => string + refusedLabel: string + mintedTitle: string + mintedOnce: string + copy: string + copied: string + done: string + nextStage: string +} + +const RUNNERS_COPY: Record<'en' | 'ru', RunnersCopy> = { + en: { + title: 'MY RUNNERS', + lead: 'A runner is a lane that runs on your own machine, under your own provider account. The Queen hands it a task; the work comes back; the XP lands here, on your name.', + keyStays: 'Your provider key never leaves your machine. This page has no field for it and the Queen never sees it — the token below only lets a process speak as your runner.', + signin: 'Sign in to app.t27.ai to manage your runners.', + elsewhere: 'Runners are managed on the app’s board, where your session lives:', + unavailable: 'The Queen did not answer. Try again in a minute.', + loading: 'Reading your runners…', + none: 'No runners yet.', + namePlaceholder: 'Name, e.g. my laptop', + create: 'Create runner', + revoke: 'Revoke', + revokeConfirm: (label) => `Revoke “${label}”? A process using its token stops at once.`, + lane: 'lane', + state: { 'never-seen': 'never connected', online: 'online', offline: 'offline' }, + limit: (n) => `Up to ${n} runners.`, + refusedLabel: 'Give the runner a name.', + mintedTitle: 'Runner token', + mintedOnce: 'Shown once. Copy it now — afterwards only its last four characters are kept.', + copy: 'Copy', + copied: 'Copied', + done: 'I saved it', + nextStage: 'Today a runner can connect and show up online. Taking tasks and handing work back is the next stage of the Queen; until it ships there is nothing to take.', + }, + ru: { + title: 'МОИ РАННЕРЫ', + lead: 'Раннер — это полоса, которая работает на вашей машине, под вашим аккаунтом провайдера. Королева даёт ему задачу, работа возвращается, а XP начисляется здесь, на ваше имя.', + keyStays: 'Ваш ключ провайдера не покидает вашу машину. На этой странице нет поля для него, и Королева его не видит — токен ниже лишь позволяет процессу говорить от имени вашего раннера.', + signin: 'Войдите в app.t27.ai, чтобы управлять раннерами.', + elsewhere: 'Раннеры управляются на доске приложения, где живёт ваша сессия:', + unavailable: 'Королева не ответила. Попробуйте через минуту.', + loading: 'Читаю ваших раннеров…', + none: 'Раннеров пока нет.', + namePlaceholder: 'Имя, например «мой ноутбук»', + create: 'Создать раннер', + revoke: 'Отозвать', + revokeConfirm: (label) => `Отозвать «${label}»? Процесс с его токеном сразу перестанет работать.`, + lane: 'полоса', + state: { 'never-seen': 'ещё не подключался', online: 'на связи', offline: 'не на связи' }, + limit: (n) => `Не больше ${n} раннеров.`, + refusedLabel: 'Дайте раннеру имя.', + mintedTitle: 'Токен раннера', + mintedOnce: 'Показывается один раз. Скопируйте сейчас — потом хранятся только его последние четыре символа.', + copy: 'Скопировать', + copied: 'Скопировано', + done: 'Сохранено', + nextStage: 'Сейчас раннер может подключиться и отображаться «на связи». Выдача задач и приём работы — следующий этап Королевы; пока его нет, брать нечего.', + }, +} + +const env: RunnersEnv = { + base: QUEEN_API, + fetch: (url, init) => window.fetch(url, init), + token: () => { + const s = appSessionFromWindow() + return s.source === 'app-session' && s.state === 'signed-in' ? s.token : null + }, +} + +export default function QueenRunners({ lang }: { lang: 'en' | 'ru' }) { + const c = RUNNERS_COPY[lang] + const session = appSessionFromWindow() + const [view, setView] = useState(null) + const [busy, setBusy] = useState(false) + const [label, setLabel] = useState('') + const [copied, setCopied] = useState(false) + + const cabinet = view && 'cabinet' in view ? view.cabinet : undefined + + const act = useCallback( + async (call: RunnersCall) => { + setBusy(true) + try { + const next = await callRunners(env, call, cabinet) + setView(next) + if (next.state === 'minted') setLabel('') + } catch { + setView({ state: 'unavailable' }) + } finally { + setBusy(false) + } + }, + [cabinet], + ) + + useEffect(() => { + if (session.source === 'app-session') void act({ kind: 'list' }) + // Once per mount: the list is re-read after every action anyway. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const head = ( +
    +

    {c.title}

    +

    {c.lead}

    +

    {c.keyStays}

    +
    + ) + + // On t27.ai the session is not in reach, and the bridge's game token is not + // this panel's to forward. Say where the cabinet is instead. + if (session.source === 'bridge') { + return ( +
    + {head} +

    + {c.elsewhere}{' '} + + app.t27.ai/queen + +

    +
    + ) + } + + if (session.state === 'signed-out' || view?.state === 'signin') { + return ( +
    + {head} +

    {c.signin}

    +
    + ) + } + + return ( +
    + {head} + {view === null &&

    {c.loading}

    } + {view?.state === 'unavailable' && ( +

    + {c.unavailable} +

    + )} + + {view?.state === 'minted' && ( +
    + + {c.mintedTitle}: {view.runner.label} + +

    {c.mintedOnce}

    +
    {setupLines(view.token, QUEEN_API).join('\n')}
    +
    + + +
    +
    + )} + + {cabinet && ( + <> + {cabinet.runners.length === 0 ? ( +

    {c.none}

    + ) : ( +
      + {cabinet.runners.map((r) => ( +
    • +
    • + ))} +
    + )} +
    { + e.preventDefault() + if (busy) return + void act({ kind: 'create', label }) + }} + > + setLabel(e.target.value)} + disabled={busy || cabinet.runners.length >= cabinet.limit} + /> + +
    +

    + {view?.state === 'refused' && view.reason === 'label' ? c.refusedLabel : c.limit(cabinet.limit)} +

    + + )} +

    {c.nextStage}

    +
    + ) +} diff --git a/apps/website/src/lib/queenRunners.ts b/apps/website/src/lib/queenRunners.ts new file mode 100644 index 0000000000..505853bbc9 --- /dev/null +++ b/apps/website/src/lib/queenRunners.ts @@ -0,0 +1,161 @@ +/** + * MY RUNNERS: the cabinet half of "lend a lane without lending a key". + * + * The swarm runs every bee on one provider key, and the honest way to add a + * lane is the one where the key never moves: a runner on the lender's own + * machine, under the lender's own account, takes a task and brings the work + * back. This page never sees that key and has no field for one. What it hands + * out is a RUNNER TOKEN — it lets a process speak as one of your runners and + * can spend nobody's quota — minted by the Queen (trios-agent-server, + * /queen/me/runners) for the person the app's own session names. + * + * WHERE THE CREDENTIAL COMES FROM. Only the app's own copy of the board + * (https://app.t27.ai/queen/) holds the person's session, read through + * appSessionIdentity.ts on the same terms as everywhere else: memory only, + * `credentials: 'omit'`, exactly two headers. On t27.ai the panel says where to + * go instead of reaching for the bridge's game token, which is scoped to + * read-only tools and is not this page's to forward. + * + * Pure apart from `env`, so qa/queen-runners-contract.mjs drives the real code. + * The Queen's address arrives in `env.base` (the component passes QUEEN_API) + * rather than being imported here: queenApi.ts reads Vite's import.meta.env, + * which a contract running under node does not have. + */ +export const CABINET_PATH = '/queen/me/runners' +/** Where a person on t27.ai is sent to manage runners: the app's board. */ +export const CABINET_HOME = 'https://app.t27.ai/queen/#/queen?tab=leaderboard' + +export interface RunnerView { + id: number + label: string + lane: number + tokenHint: string + createdAt: string + lastSeenAt: string | null + state: 'never-seen' | 'online' | 'offline' +} + +export interface Cabinet { + runners: RunnerView[] + limit: number +} + +/** What the panel can be showing. A token appears only in `minted`, once. */ +export type CabinetView = + | { state: 'signin' } + | { state: 'unavailable' } + | { state: 'ready'; cabinet: Cabinet } + | { state: 'minted'; cabinet: Cabinet; token: string; runner: RunnerView } + | { state: 'refused'; cabinet: Cabinet; reason: 'limit' | 'label' } + +export interface RunnersEnv { + fetch: ( + url: string, + init: { method: string; credentials: 'omit'; headers: Record; body?: string }, + ) => Promise<{ ok: boolean; status: number; json(): Promise }> + /** The app session's access token, or null when nobody is signed in here. */ + token: () => string | null + /** The Queen's origin, e.g. QUEEN_API. */ + base: string +} + +const isRecord = (v: unknown): v is Record => + !!v && typeof v === 'object' && !Array.isArray(v) + +const STATES = new Set(['never-seen', 'online', 'offline']) + +/** One runner from the wire, or null. Everything is DATA: rendered as text. */ +export function runnerOf(raw: unknown): RunnerView | null { + if (!isRecord(raw)) return null + const { id, label, lane, tokenHint, createdAt, lastSeenAt, state } = raw + if (!Number.isSafeInteger(id) || typeof label !== 'string' || !Number.isSafeInteger(lane)) return null + if (typeof tokenHint !== 'string' || !/^[A-Za-z0-9_-]{4}$/.test(tokenHint)) return null + if (typeof state !== 'string' || !STATES.has(state)) return null + return { + id: id as number, + label: label.slice(0, 40), + lane: lane as number, + tokenHint, + createdAt: typeof createdAt === 'string' ? createdAt : '', + lastSeenAt: typeof lastSeenAt === 'string' ? lastSeenAt : null, + state: state as RunnerView['state'], + } +} + +export function cabinetOf(raw: unknown): Cabinet { + const body = isRecord(raw) ? raw : {} + const runners = Array.isArray(body.runners) + ? body.runners.map(runnerOf).filter((r): r is RunnerView => r !== null) + : [] + const limit = Number.isSafeInteger(body.limit) && (body.limit as number) > 0 ? (body.limit as number) : 5 + return { runners, limit } +} + +/** A runner token as the Queen mints it, and nothing that merely resembles one. */ +export const RUNNER_TOKEN = /^qr_[A-Za-z0-9_-]{43}$/ + +export type RunnersCall = + | { kind: 'list' } + | { kind: 'create'; label: string } + | { kind: 'revoke'; id: number } + +/** + * One call to the cabinet, and the view it leaves. `previous` is what the panel + * showed, so a refused create keeps the list on screen instead of blanking it. + */ +export async function callRunners(env: RunnersEnv, call: RunnersCall, previous?: Cabinet): Promise { + const token = env.token() + if (!token) return { state: 'signin' } + const base = `${env.base.replace(/\/+$/, '')}${CABINET_PATH}` + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } + const list = async (): Promise => { + const res = await env.fetch(base, { method: 'GET', credentials: 'omit', headers }) + if (res.status === 401) return { state: 'signin' } + if (!res.ok) return { state: 'unavailable' } + return { state: 'ready', cabinet: cabinetOf(await res.json().catch(() => ({}))) } + } + + if (call.kind === 'list') return list() + + if (call.kind === 'revoke') { + if (!Number.isSafeInteger(call.id) || call.id <= 0) return list() + const res = await env.fetch(`${base}/${call.id}`, { method: 'DELETE', credentials: 'omit', headers }) + if (res.status === 401) return { state: 'signin' } + if (!res.ok && res.status !== 404) return { state: 'unavailable' } + return list() + } + + const label = call.label.replace(/\s+/g, ' ').trim().slice(0, 40) + const kept = previous ?? { runners: [], limit: 5 } + if (!label) return { state: 'refused', cabinet: kept, reason: 'label' } + const res = await env.fetch(base, { + method: 'POST', + credentials: 'omit', + headers, + body: JSON.stringify({ label }), + }) + if (res.status === 401) return { state: 'signin' } + if (res.status === 409) return { state: 'refused', cabinet: kept, reason: 'limit' } + if (res.status === 400) return { state: 'refused', cabinet: kept, reason: 'label' } + if (!res.ok) return { state: 'unavailable' } + const body = await res.json().catch(() => ({})) + const runner = isRecord(body) ? runnerOf(body.runner) : null + const minted = isRecord(body) && typeof body.token === 'string' && RUNNER_TOKEN.test(body.token) ? body.token : null + if (!runner || !minted) return { state: 'unavailable' } + const after = await list() + const cabinet = after.state === 'ready' ? after.cabinet : { ...kept, runners: [...kept.runners, runner] } + return { state: 'minted', cabinet, token: minted, runner } +} + +/** + * The lines a person pastes on their own machine. The provider key is named as + * an environment variable THEY set there and is never part of anything this + * page writes, stores or sends. + */ +export function setupLines(token: string, base: string): string[] { + return [ + `export TRIOS_QUEEN_URL=${base}`, + `export TRIOS_RUNNER_TOKEN=${token}`, + `curl -fsS -X POST -H "Authorization: Bearer $TRIOS_RUNNER_TOKEN" "$TRIOS_QUEEN_URL/queen/runner/heartbeat"`, + ] +}