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 / operator / cloud.ts
web/operator/cloud.ts66 lines · 2879 bytes
1// The Cloudflare Pages Functions side of the operator (functions/api/operator/): Web Request in, Response out.
2// Plain Web APIs only, so the Node tests call these handlers directly with real Request objects.
3// The operator is on only when both secrets exist (`wrangler pages secret put`, see web/README.md):
4// OWNER_KEY the AirlockAdmin owner's private key (hex)
5// OPERATOR_TOKEN the operator's Bearer token (64 hex characters)
6import { JSON_HEADERS, MAX_BODY_BYTES, operatorInfo, propose, type ApiResult, type OperatorConfig } from './operator.ts'
7
8export interface CloudEnv {
9 OWNER_KEY?: string
10 OPERATOR_TOKEN?: string
11}
12
13export function cloudConfig(env: CloudEnv): OperatorConfig | null {
14 const key = env.OWNER_KEY
15 const token = env.OPERATOR_TOKEN
16 if (!key || !token) return null
17 return { auth: 'token', token, ownerKey: () => key }
18}
19
20export function toResponse(r: ApiResult, extra: Record<string, string> = {}): Response {
21 return new Response(JSON.stringify(r.body), { status: r.status, headers: { ...JSON_HEADERS, ...extra } })
22}
23
24const methodNotAllowed = (allow: string) => toResponse({ status: 405, body: { ok: false, error: 'method' } }, { allow })
25
26/** Reads at most `max` bytes of the body; null when there is more (the rest is never read) */
27export async function readCapped(request: Request, max = MAX_BODY_BYTES): Promise<string | null> {
28 if (Number(request.headers.get('content-length') ?? '0') > max) return null
29 if (!request.body) return ''
30 const reader = request.body.getReader()
31 const parts: Uint8Array[] = []
32 let size = 0
33 for (;;) {
34 const { done, value } = await reader.read()
35 if (done) break
36 size += value.byteLength
37 if (size > max) {
38 await reader.cancel().catch(() => {})
39 return null
40 }
41 parts.push(value)
42 }
43 const all = new Uint8Array(size)
44 let at = 0
45 for (const p of parts) {
46 all.set(p, at)
47 at += p.byteLength
48 }
49 return new TextDecoder().decode(all)
50}
51
52/** GET /api/operator */
53export async function handleInfoRequest(request: Request, env: CloudEnv): Promise<Response> {
54 if (request.method !== 'GET') return methodNotAllowed('GET')
55 return toResponse({ status: 200, body: { ...operatorInfo(cloudConfig(env)) } })
56}
57
58/** POST /api/operator/propose */
59export async function handleProposeRequest(request: Request, env: CloudEnv): Promise<Response> {
60 if (request.method !== 'POST') return methodNotAllowed('POST')
61 // the Bearer token already stops cross-site requests; a foreign Origin is refused on top of it
62 const origin = request.headers.get('origin')
63 if (origin !== null && origin !== new URL(request.url).origin) return toResponse({ status: 403, body: { ok: false, error: 'forbidden' } })
64 const body = await readCapped(request)
65 return toResponse(await propose({ authorization: request.headers.get('authorization'), body }, cloudConfig(env)))
66}