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 / vite.config.ts
web/vite.config.ts146 lines · 7331 bytes
1// Dev server and build for the Upgrade Airlock war room.
2// AIRLOCK_DEPLOYMENT=<DeployEth output JSON> serves that deployment instead of src/chain-data-sepolia.json, so a local
3// fork rehearsal (open with ?rpc=http://127.0.0.1:<port>) never touches the tracked data file:
4// AIRLOCK_DEPLOYMENT=$TMPDIR/deployment.json npx vite --port 5291
5//
6// Operator mode (web/README.md): with AIRLOCK_OPERATOR=1 the dev server also answers GET /api/operator and
7// POST /api/operator/propose, so this page can launch a real attack card on Ethereum Sepolia as the AirlockAdmin owner.
8// AIRLOCK_OPERATOR=1 AIRLOCK_KEYS_DIR=<folder with owner.key> npm run dev
9// The key is read from AIRLOCK_OWNER_KEY_FILE (default: owner.key in AIRLOCK_KEYS_DIR) on each request, never
10// printed and never cached. Only loopback requests are answered (the peer address must be loopback, Host
11// 127.0.0.1:<port> or localhost:<port>, and a matching Origin), so neither another device on the network, another site
12// nor a DNS-rebound name can reach it; with a non-loopback server.host (`vite --host`) the operator stays off. AIRLOCK_OPERATOR_RPC=<url>[,<url>]
13// replaces the Sepolia RPCs (a local fork); with AIRLOCK_DEPLOYMENT it is required, so fork addresses never go to
14// Sepolia. Without AIRLOCK_OPERATOR=1 both routes say {enabled: false}. `vite build` / `vite preview` have no API.
15import { readFileSync } from 'node:fs'
16import { join } from 'node:path'
17import type { ServerResponse } from 'node:http'
18import { defineConfig, type Connect, type Plugin } from 'vite'
19import { localRequestAllowed, loopbackBind, JSON_HEADERS, MAX_BODY_BYTES, operatorInfo, propose, type ApiResult, type OperatorConfig } from './operator/operator.ts'
20import { buildChainData, operatorDeployment } from './scripts/sync-sepolia-data.mjs'
21
22function deploymentOverride(file: string): Plugin {
23 return {
24 name: 'airlock-deployment-override',
25 enforce: 'pre',
26 load(id) {
27 if (!id.split('?')[0].endsWith('/src/chain-data-sepolia.json')) return null
28 return JSON.stringify(buildChainData(JSON.parse(readFileSync(file, 'utf8'))))
29 },
30 }
31}
32
33const override = process.env.AIRLOCK_DEPLOYMENT
34
35/** The local operator's settings, read from the environment on every request (null = off) */
36function localOperator(): OperatorConfig | null {
37 const env = process.env
38 if (env.AIRLOCK_OPERATOR !== '1') return null
39 const keyFile = env.AIRLOCK_OWNER_KEY_FILE || (env.AIRLOCK_KEYS_DIR ? join(env.AIRLOCK_KEYS_DIR, 'owner.key') : '')
40 const rpcUrls = (env.AIRLOCK_OPERATOR_RPC ?? '')
41 .split(',')
42 .map((s) => s.trim())
43 .filter((s) => /^https?:\/\/\S+$/.test(s))
44 if (override && !rpcUrls.length) return null
45 return {
46 auth: 'none',
47 ownerKey: () => {
48 if (!keyFile) return undefined
49 try {
50 return readFileSync(keyFile, 'utf8')
51 } catch {
52 return undefined
53 }
54 },
55 rpcUrls: rpcUrls.length ? rpcUrls : undefined,
56 deployment: override ? operatorDeployment(buildChainData(JSON.parse(readFileSync(override, 'utf8')))) : undefined,
57 }
58}
59
60function sendJson(res: ServerResponse, r: ApiResult, extra: Record<string, string> = {}) {
61 if (res.headersSent) return void res.end()
62 res.writeHead(r.status, { ...JSON_HEADERS, ...extra })
63 res.end(JSON.stringify(r.body))
64}
65
66/** The request body, or null when it is over MAX_BODY_BYTES (the rest is not kept) */
67function readBody(req: Connect.IncomingMessage): Promise<string | null> {
68 return new Promise((resolve) => {
69 const parts: Buffer[] = []
70 let size = 0
71 let over = false
72 req.on('data', (c: Buffer) => {
73 size += c.length
74 if (size > MAX_BODY_BYTES) over = true
75 else parts.push(c)
76 })
77 req.on('end', () => resolve(over ? null : Buffer.concat(parts).toString('utf8')))
78 req.on('error', () => resolve(null))
79 })
80}
81
82const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v)
83
84function operatorApi(): Plugin {
85 return {
86 name: 'airlock-operator-api',
87 apply: 'serve',
88 configureServer(server) {
89 const log = server.config.logger
90 // a dev server reachable from the network never signs: the operator API needs a loopback-only bind
91 const bindOk = loopbackBind(server.config.server.host)
92 const config = (): OperatorConfig | null => (bindOk ? localOperator() : null)
93 const cfg = config()
94 log.info(
95 !bindOk && process.env.AIRLOCK_OPERATOR === '1'
96 ? ' operator: off (server.host is not loopback, e.g. --host; the operator API only runs on a 127.0.0.1 bind)'
97 : cfg
98 ? ` operator: ON, local only (${operatorInfo(cfg).enabled ? 'owner key file readable' : 'owner key file missing or not a 32-byte hex key: AIRLOCK_OWNER_KEY_FILE or AIRLOCK_KEYS_DIR'})`
99 : process.env.AIRLOCK_OPERATOR === '1'
100 ? ' operator: off (AIRLOCK_DEPLOYMENT is set without AIRLOCK_OPERATOR_RPC)'
101 : ' operator: off (AIRLOCK_OPERATOR=1 turns it on)',
102 )
103 server.middlewares.use((req, res, next) => {
104 const path = (req.url ?? '').split('?')[0]
105 if (path !== '/api/operator' && path !== '/api/operator/propose') return next()
106 const allowed = localRequestAllowed(
107 {
108 remote: req.socket.remoteAddress,
109 host: one(req.headers.host),
110 origin: one(req.headers.origin),
111 secFetchSite: one(req.headers['sec-fetch-site']),
112 },
113 req.socket.localPort ?? 0,
114 )
115 if (!allowed) return sendJson(res, { status: 403, body: { ok: false, error: 'forbidden' } })
116 const run = async () => {
117 if (path === '/api/operator') {
118 if (req.method !== 'GET') return sendJson(res, { status: 405, body: { ok: false, error: 'method' } }, { allow: 'GET' })
119 return sendJson(res, { status: 200, body: { ...operatorInfo(config()) } })
120 }
121 if (req.method !== 'POST') return sendJson(res, { status: 405, body: { ok: false, error: 'method' } }, { allow: 'POST' })
122 const r = await propose({ authorization: one(req.headers.authorization), body: await readBody(req) }, config())
123 if (r.body.ok) log.info(` operator: proposed ${String(r.body.card)}, tx ${String(r.body.txHash)}`)
124 else log.info(` operator: refused (${r.status} ${String(r.body.error)})`)
125 sendJson(res, r)
126 }
127 run().catch(() => sendJson(res, { status: 500, body: { ok: false, error: 'internal' } }))
128 })
129 },
130 // `vite preview` of a static build has no operator: answer "disabled" instead of a 404 the console would show
131 configurePreviewServer(server) {
132 server.middlewares.use((req, res, next) => {
133 const path = (req.url ?? '').split('?')[0]
134 if (path === '/api/operator' && req.method === 'GET') return sendJson(res, { status: 200, body: { ...operatorInfo(null) } })
135 if (path === '/api/operator' || path === '/api/operator/propose') return sendJson(res, { status: 403, body: { ok: false, enabled: false, error: 'disabled' } })
136 next()
137 })
138 },
139 }
140}
141
142export default defineConfig({
143 plugins: [...(override ? [deploymentOverride(override)] : []), operatorApi()],
144 server: { host: '127.0.0.1', port: 5173, strictPort: true },
145 preview: { host: '127.0.0.1', port: 5173, strictPort: true },
146})