Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / src / operator-client.ts
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.
26import { isLabId, LAB_IDS, type LabId } from './lab'
27import type { Proposal } from './store'
28
29export 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}
36export 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 */
39export 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) */
49export 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 */
52export 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 */
74const 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 */
77export 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 */
82export function operatorVisible(info: OperatorInfo, token: string | null): boolean {
83 return info.enabled && info.cards.length > 0 && (info.auth === 'none' || !!token)
84}
85
86export type LaunchError = 'unknown card' | 'unauthorized' | 'disabled' | 'forbidden' | 'cooldown' | 'reverted' | 'misconfigured' | 'rpc' | 'server' | 'unclear' | 'network'
87export type LaunchResult = { ok: true; card: LabId; txHash: string } | { ok: false; error: LaunchError; retryAfterBlocks?: number }
88
89const 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) */
91export 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
112export 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 */
125export type LaunchStage = 'sending' | 'sent' | 'slow' | 'onchain' | 'veto' | 'clear' | 'exec' | 'error'
126export const SLOW_MS = 4 * 60_000
127export 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 ----------
141const LS_KEY = 'airlock.op'
142export 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
161export const opVisible = () => operatorVisible(op.info, op.token)
162
163function 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}
171export 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}
181export 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 */
193export 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. */
208export 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 */
228export 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 */
236export 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. */
244export 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}