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. |
| 15 | import { readFileSync } from 'node:fs' |
| 16 | import { join } from 'node:path' |
| 17 | import type { ServerResponse } from 'node:http' |
| 18 | import { defineConfig, type Connect, type Plugin } from 'vite' |
| 19 | import { localRequestAllowed, loopbackBind, JSON_HEADERS, MAX_BODY_BYTES, operatorInfo, propose, type ApiResult, type OperatorConfig } from './operator/operator.ts' |
| 20 | import { buildChainData, operatorDeployment } from './scripts/sync-sepolia-data.mjs' |
| 21 | |
| 22 | function 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 | |
| 33 | const override = process.env.AIRLOCK_DEPLOYMENT |
| 34 | |
| 35 | /** The local operator's settings, read from the environment on every request (null = off) */ |
| 36 | function 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 | |
| 60 | function 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) */ |
| 67 | function 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 | |
| 82 | const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) |
| 83 | |
| 84 | function 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 | |
| 142 | export 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 | }) |