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. */}
+
+
{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}
+
+
+ )
+}
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"`,
+ ]
+}