web/src/operator-client.ts279 lines · 13805 bytes
| 1 | // Operator mode (the presenter only): launch one of the seven demo actions as a REAL Ethereum Sepolia transaction |
| 2 | // through the operator API, then let the live war room pick up the proposal and its verdict. This page never holds a |
| 3 | // key and never signs: it sends {card} (one of seven fixed ids, never an address or calldata), the server signs. |
| 4 | // |
| 5 | // Invisible to the public: nothing operator-related is drawn unless GET /api/operator says enabled AND (auth is 'none' |
| 6 | // [local dev] or this browser holds the operator token). A static deploy without the API answers 404: disabled. |
| 7 | // ?op=1 asks for the token in a small prompt (when the API says it is needed): the safest way on a stage |
| 8 | // machine, since the token never sits in an address |
| 9 | // #op=<token> keeps the token in this browser (localStorage) and removes it from the address bar at once; a |
| 10 | // fragment is never sent to the server, so it stays out of request logs |
| 11 | // ?op=<token> the same, as a query (it does reach the server's request log once, and the browser's history) |
| 12 | // Offline playback and a local fork (?rpc=) never ask the API: their screen is not Ethereum Sepolia. Neither does a |
| 13 | // public visitor's page: the API is only asked on a loopback host (local dev) or when this browser holds a token or |
| 14 | // ?op=1 asked for one, so a static deploy logs no 404 and a visitor's page never calls the API or draws anything of |
| 15 | // operator mode. GET /api/operator itself is public, though: anyone who requests it learns whether operator mode is |
| 16 | // on, its auth kind, the card ids and the cooldown (nothing else). Launching needs the operator token. |
| 17 | // |
| 18 | // API contract: |
| 19 | // GET /api/operator -> {enabled, auth: 'none'|'token', cards: string[], cooldownBlocks} (public, no token) |
| 20 | // POST /api/operator/propose {card} (+ Authorization: Bearer <token> when auth is 'token') |
| 21 | // 200 {ok, card, txHash} | 400 unknown card | 401 unauthorized | 409 cooldown (retryAfterBlocks) | 502 rpc |
| 22 | // and from the operator service also 403 disabled (operator off) | 403 forbidden (origin/host guard) | |
| 23 | // 405 method | 422 reverted (its dry run reverted, nothing sent) | 500 misconfigured (the key is not the |
| 24 | // owner's) | 500 internal |
| 25 | // Every answer gets its own wording on screen (op.err*); the server's own words are never shown. |
| 26 | import { isLabId, LAB_IDS, type LabId } from './lab' |
| 27 | import type { Proposal } from './store' |
| 28 | |
| 29 | export interface OperatorInfo { |
| 30 | enabled: boolean |
| 31 | auth: 'none' | 'token' |
| 32 | /** card ids the server will propose (a subset of the seven) */ |
| 33 | cards: LabId[] |
| 34 | cooldownBlocks: number |
| 35 | } |
| 36 | export const OP_DISABLED: OperatorInfo = { enabled: false, auth: 'token', cards: [], cooldownBlocks: 0 } |
| 37 | |
| 38 | /** GET /api/operator's answer, checked field by field; anything unexpected means disabled */ |
| 39 | export function parseOperatorInfo(status: number, body: unknown): OperatorInfo { |
| 40 | if (status !== 200 || !body || typeof body !== 'object') return OP_DISABLED |
| 41 | const b = body as Record<string, unknown> |
| 42 | if (b.enabled !== true || (b.auth !== 'none' && b.auth !== 'token') || !Array.isArray(b.cards)) return OP_DISABLED |
| 43 | const cards = LAB_IDS.filter((id) => (b.cards as unknown[]).includes(id)) |
| 44 | const cd = Number(b.cooldownBlocks) |
| 45 | return { enabled: cards.length > 0, auth: b.auth, cards, cooldownBlocks: Number.isFinite(cd) && cd >= 0 ? Math.floor(cd) : 0 } |
| 46 | } |
| 47 | |
| 48 | /** What a token may look like (URL-safe; long enough to be a secret, short enough for a header) */ |
| 49 | export const TOKEN_RE = /^[A-Za-z0-9._~+/=-]{16,512}$/ |
| 50 | |
| 51 | /** ?op= or #op= in an address: the token it carries (or the prompt flag), and the same address without it */ |
| 52 | export function tokenFromUrl(href: string): { token: string | null; prompt: boolean; stripped: string | null } { |
| 53 | let u: URL |
| 54 | try { |
| 55 | u = new URL(href) |
| 56 | } catch { |
| 57 | return { token: null, prompt: false, stripped: null } |
| 58 | } |
| 59 | const hp = new URLSearchParams(u.hash.replace(/^#/, '')) |
| 60 | const inQuery = u.searchParams.has('op') |
| 61 | const inHash = /^#.*\bop=/.test(u.hash) && hp.has('op') |
| 62 | if (!inQuery && !inHash) return { token: null, prompt: false, stripped: null } |
| 63 | // the fragment wins (it never reached a server); both are removed either way |
| 64 | const raw = ((inHash ? hp.get('op') : u.searchParams.get('op')) ?? '').trim() |
| 65 | u.searchParams.delete('op') |
| 66 | hp.delete('op') |
| 67 | const hash = inHash ? (hp.toString() ? `#${hp}` : '') : u.hash |
| 68 | const stripped = u.pathname + (u.searchParams.toString() ? `?${u.searchParams}` : '') + hash |
| 69 | if (raw === '1') return { token: null, prompt: true, stripped } |
| 70 | return { token: TOKEN_RE.test(raw) ? raw : null, prompt: false, stripped } |
| 71 | } |
| 72 | |
| 73 | /** The local dev server's hosts: the only place the API answers without a token */ |
| 74 | const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]', '::1'] |
| 75 | |
| 76 | /** This browser may be the operator's, so the API is worth asking: a loopback host, a saved token, or ?op=1 */ |
| 77 | export function mayOperate(hostname: string | null | undefined): boolean { |
| 78 | return LOOPBACK_HOSTS.includes((hostname ?? '').toLowerCase()) || !!op.token || op.prompt |
| 79 | } |
| 80 | |
| 81 | /** The operator UI may be drawn */ |
| 82 | export function operatorVisible(info: OperatorInfo, token: string | null): boolean { |
| 83 | return info.enabled && info.cards.length > 0 && (info.auth === 'none' || !!token) |
| 84 | } |
| 85 | |
| 86 | export type LaunchError = 'unknown card' | 'unauthorized' | 'disabled' | 'forbidden' | 'cooldown' | 'reverted' | 'misconfigured' | 'rpc' | 'server' | 'unclear' | 'network' |
| 87 | export type LaunchResult = { ok: true; card: LabId; txHash: string } | { ok: false; error: LaunchError; retryAfterBlocks?: number } |
| 88 | |
| 89 | const TXHASH = /^0x[0-9a-fA-F]{64}$/ |
| 90 | /** POST /api/operator/propose's answer -> a result the page can show (never the server's own words) */ |
| 91 | export function parseLaunchResponse(status: number, body: unknown): LaunchResult { |
| 92 | const b = (body && typeof body === 'object' ? body : {}) as Record<string, unknown> |
| 93 | if (status === 200 && b.ok === true && typeof b.txHash === 'string' && TXHASH.test(b.txHash) && isLabId(b.card)) return { ok: true, card: b.card, txHash: b.txHash } |
| 94 | // a 2xx without a transaction hash: the transaction may or may not be out (the live war room picks it up if it is) |
| 95 | if (status >= 200 && status < 300) return { ok: false, error: 'unclear' } |
| 96 | if (status === 400) return { ok: false, error: 'unknown card' } |
| 97 | if (status === 401) return { ok: false, error: 'unauthorized' } |
| 98 | // 403: operator mode switched off on the server, or this page's origin refused by its guard (the UI stays) |
| 99 | if (status === 403) return { ok: false, error: b.error === 'disabled' || b.enabled === false ? 'disabled' : 'forbidden' } |
| 100 | if (status === 404) return { ok: false, error: 'disabled' } |
| 101 | if (status === 409) { |
| 102 | const n = Number(b.retryAfterBlocks) |
| 103 | return { ok: false, error: 'cooldown', ...(Number.isFinite(n) && n > 0 ? { retryAfterBlocks: Math.ceil(n) } : {}) } |
| 104 | } |
| 105 | if (status === 422) return { ok: false, error: 'reverted' } |
| 106 | if (status === 500 && b.error === 'misconfigured') return { ok: false, error: 'misconfigured' } |
| 107 | if (status === 502) return { ok: false, error: 'rpc' } |
| 108 | if (status >= 400) return { ok: false, error: 'server' } |
| 109 | return { ok: false, error: 'network' } |
| 110 | } |
| 111 | |
| 112 | export interface Launch { |
| 113 | card: LabId |
| 114 | state: 'sending' | 'sent' | 'error' |
| 115 | /** when it was sent (ms) */ |
| 116 | at: number |
| 117 | txHash?: string |
| 118 | error?: LaunchError |
| 119 | retryAfterBlocks?: number |
| 120 | /** when its verdict was first seen on screen (ms): the status toast fades some time after */ |
| 121 | doneAt?: number |
| 122 | } |
| 123 | |
| 124 | /** Where a sent launch stands on chain, from the proposals the war room already reads */ |
| 125 | export type LaunchStage = 'sending' | 'sent' | 'slow' | 'onchain' | 'veto' | 'clear' | 'exec' | 'error' |
| 126 | export const SLOW_MS = 4 * 60_000 |
| 127 | export function launchStage(l: Launch, proposals: Iterable<Proposal>, now = Date.now()): { stage: LaunchStage; p: Proposal | null } { |
| 128 | if (l.state === 'sending') return { stage: 'sending', p: null } |
| 129 | if (l.state === 'error' || !l.txHash) return { stage: 'error', p: null } |
| 130 | const tx = l.txHash.toLowerCase() |
| 131 | const p = [...proposals].find((x) => x.proposeTx?.toLowerCase() === tx) ?? null |
| 132 | if (!p) return { stage: now - l.at > SLOW_MS ? 'slow' : 'sent', p: null } |
| 133 | const vetoed = p.chain?.status === 2 || Object.values(p.guards).some((g) => g.verdict === 'veto') |
| 134 | if (vetoed) return { stage: 'veto', p } |
| 135 | if (p.execute || p.chain?.status === 3) return { stage: 'exec', p } |
| 136 | if (p.clearance || p.chain?.clearanceOf) return { stage: 'clear', p } |
| 137 | return { stage: 'onchain', p } |
| 138 | } |
| 139 | |
| 140 | // ---------- page state ---------- |
| 141 | const LS_KEY = 'airlock.op' |
| 142 | export const op = { |
| 143 | info: OP_DISABLED, |
| 144 | /** GET /api/operator answered (or failed) */ |
| 145 | loaded: false, |
| 146 | token: null as string | null, |
| 147 | /** ?op=1: the token prompt is open */ |
| 148 | prompt: false, |
| 149 | promptBad: false, |
| 150 | /** the launch panel is open, and the card waiting for its confirm step */ |
| 151 | panel: false, |
| 152 | confirm: null as LabId | null, |
| 153 | launch: null as Launch | null, |
| 154 | /** the viewer closed the status toast of the current launch */ |
| 155 | toastHidden: false, |
| 156 | /** a one-line notice after the operator UI went away (the token was rejected, operator mode was switched off) */ |
| 157 | notice: null as 'auth' | 'disabled' | null, |
| 158 | noticeAt: 0, |
| 159 | } |
| 160 | |
| 161 | export const opVisible = () => operatorVisible(op.info, op.token) |
| 162 | |
| 163 | function readToken(): string | null { |
| 164 | try { |
| 165 | const t = globalThis.localStorage?.getItem(LS_KEY) ?? null |
| 166 | return t && TOKEN_RE.test(t) ? t : null |
| 167 | } catch { |
| 168 | return null |
| 169 | } |
| 170 | } |
| 171 | export function saveToken(t: string): boolean { |
| 172 | if (!TOKEN_RE.test(t)) return false |
| 173 | op.token = t |
| 174 | try { |
| 175 | globalThis.localStorage?.setItem(LS_KEY, t) |
| 176 | } catch { |
| 177 | /* private mode: the token lasts for this page only */ |
| 178 | } |
| 179 | return true |
| 180 | } |
| 181 | export function forgetToken() { |
| 182 | op.token = null |
| 183 | op.panel = false |
| 184 | op.confirm = null |
| 185 | try { |
| 186 | globalThis.localStorage?.removeItem(LS_KEY) |
| 187 | } catch { |
| 188 | /* ignore */ |
| 189 | } |
| 190 | } |
| 191 | |
| 192 | /** Step 1, synchronous and first thing on page load: take ?op= out of the address bar before anything else runs */ |
| 193 | export function takeTokenFromUrl() { |
| 194 | try { |
| 195 | const r = tokenFromUrl(globalThis.location?.href ?? '') |
| 196 | if (r.stripped !== null) { |
| 197 | if (r.token) saveToken(r.token) |
| 198 | op.prompt = r.prompt |
| 199 | globalThis.history?.replaceState?.(globalThis.history.state, '', r.stripped) |
| 200 | } |
| 201 | } catch { |
| 202 | /* no history API: nothing to strip */ |
| 203 | } |
| 204 | op.token = op.token ?? readToken() |
| 205 | } |
| 206 | |
| 207 | /** Step 2: ask the API whether operator mode exists here. A 404, a network error or a bad answer: disabled. */ |
| 208 | export async function loadOperator(fetchImpl: typeof fetch = fetch, timeoutMs = 8000): Promise<OperatorInfo> { |
| 209 | const ctl = typeof AbortController === 'function' ? new AbortController() : null |
| 210 | const timer = ctl ? setTimeout(() => ctl.abort(), timeoutMs) : undefined |
| 211 | try { |
| 212 | const r = await fetchImpl('/api/operator', { cache: 'no-store', headers: { accept: 'application/json' }, signal: ctl?.signal }) |
| 213 | let body: unknown = null |
| 214 | if (r.status === 200) body = await r.json().catch(() => null) |
| 215 | op.info = parseOperatorInfo(r.status, body) |
| 216 | } catch { |
| 217 | op.info = OP_DISABLED |
| 218 | } finally { |
| 219 | if (timer) clearTimeout(timer) |
| 220 | op.loaded = true |
| 221 | } |
| 222 | // the prompt only makes sense where a token is needed and missing |
| 223 | if (!(op.info.enabled && op.info.auth === 'token' && !op.token)) op.prompt = false |
| 224 | return op.info |
| 225 | } |
| 226 | |
| 227 | /** A launch is on its way and has not been seen on chain yet: one at a time */ |
| 228 | export function launchBusy(proposals: Iterable<Proposal>, now = Date.now()): boolean { |
| 229 | const l = op.launch |
| 230 | if (!l) return false |
| 231 | const s = launchStage(l, proposals, now).stage |
| 232 | return s === 'sending' || s === 'sent' |
| 233 | } |
| 234 | |
| 235 | /** The operator's launch on its way (sending, or sent and not on chain yet): the war room shows it as pending */ |
| 236 | export function pendingLaunch(proposals: Iterable<Proposal>, now = Date.now()): Launch | null { |
| 237 | const l = op.launch |
| 238 | if (!l || !opVisible()) return null |
| 239 | const s = launchStage(l, proposals, now).stage |
| 240 | return s === 'sending' || s === 'sent' ? l : null |
| 241 | } |
| 242 | |
| 243 | /** Send one card. onSent runs once the server answered with a transaction hash. */ |
| 244 | export async function launchCard(card: LabId, proposals: Iterable<Proposal>, onSent: () => void, fetchImpl: typeof fetch = fetch): Promise<LaunchResult | null> { |
| 245 | if (!opVisible() || !op.info.cards.includes(card) || launchBusy(proposals)) return null |
| 246 | op.confirm = null |
| 247 | op.toastHidden = false |
| 248 | op.launch = { card, state: 'sending', at: Date.now() } |
| 249 | const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' } |
| 250 | if (op.info.auth === 'token' && op.token) headers.authorization = `Bearer ${op.token}` |
| 251 | let res: LaunchResult |
| 252 | try { |
| 253 | const r = await fetchImpl('/api/operator/propose', { method: 'POST', headers, body: JSON.stringify({ card }), cache: 'no-store' }) |
| 254 | const body = await r.json().catch(() => null) |
| 255 | res = parseLaunchResponse(r.status, body) |
| 256 | } catch { |
| 257 | res = { ok: false, error: 'network' } |
| 258 | } |
| 259 | if (res.ok) { |
| 260 | op.launch = { card, state: 'sent', at: Date.now(), txHash: res.txHash } |
| 261 | onSent() |
| 262 | } else { |
| 263 | op.launch = { card, state: 'error', at: Date.now(), error: res.error, ...(res.retryAfterBlocks ? { retryAfterBlocks: res.retryAfterBlocks } : {}) } |
| 264 | if (res.error === 'unauthorized' && op.info.auth === 'token') { |
| 265 | // a wrong or rotated token: forget it, the operator UI disappears, a notice says why |
| 266 | forgetToken() |
| 267 | op.notice = 'auth' |
| 268 | op.noticeAt = Date.now() |
| 269 | } else if (res.error === 'disabled') { |
| 270 | // switched off on the server since the page loaded: the operator UI disappears |
| 271 | op.info = OP_DISABLED |
| 272 | op.panel = false |
| 273 | op.confirm = null |
| 274 | op.notice = 'disabled' |
| 275 | op.noticeAt = Date.now() |
| 276 | } |
| 277 | } |
| 278 | return res |
| 279 | } |