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) |
| 6 | import { JSON_HEADERS, MAX_BODY_BYTES, operatorInfo, propose, type ApiResult, type OperatorConfig } from './operator.ts' |
| 7 | |
| 8 | export interface CloudEnv { |
| 9 | OWNER_KEY?: string |
| 10 | OPERATOR_TOKEN?: string |
| 11 | } |
| 12 | |
| 13 | export 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 | |
| 20 | export 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 | |
| 24 | const 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) */ |
| 27 | export 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 */ |
| 53 | export 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 */ |
| 59 | export 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 | } |