#!/usr/bin/env bun // Operator commands. They edit the same SQLite file the running gateway reads, so changes apply // within a few seconds. In the container: `podman exec -it wristcode-gateway wristcode `. import { loadConfig } from "./config.ts"; import { openDb } from "./db.ts"; import { hostUrls } from "./hub.ts"; import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts"; const USAGE = `wristcode host add [--label ] [--alt-url ]... Connect a T3 Code server. Make the credential on that host with: t3 auth pairing create --scope orchestration:read --scope orchestration:operate --label "WristCode gateway" Run it again with a fresh credential to renew the gateway's token. --alt-url adds a fallback route to the same server, such as a VPN address, tried in order when does not answer. Repeat it for more. host list host remove pair Print a 6 digit code to enter on the watch. Good for 5 minutes. device list device revoke `; const PAIRING_CODE_TTL_MS = 5 * 60_000; const db = openDb(loadConfig().dbPath); const [group, command, ...rest] = process.argv.slice(2); try { await run(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; } finally { db.close(); } async function run() { switch (`${group} ${command ?? ""}`.trim()) { case "host add": return hostAdd(rest); case "host list": for (const host of db.hosts.list()) { console.log(`${host.label}\t${hostUrls(host).join(" ")}\t${host.id}\ttoken expires ${host.token_expires_at ?? "unknown"}`); } return; case "host remove": if (!rest[0]) throw new Error(USAGE); console.log(db.hosts.remove(rest[0]) ? "Removed." : "No such host."); return; case "pair": { const code = String(crypto.getRandomValues(new Uint32Array(1))[0]! % 1_000_000).padStart(6, "0"); db.pairing.create(code, PAIRING_CODE_TTL_MS); console.log(`Enter this code on the watch within 5 minutes: ${code}`); return; } case "device list": for (const device of db.devices.list()) { const state = device.revoked_at ? "revoked" : device.apns_token ? `push ${device.apns_env}` : "no push"; console.log(`${device.name}\t${device.id}\t${state}\tlast seen ${device.last_seen_at ?? "never"}`); } return; case "device revoke": if (!rest[0]) throw new Error(USAGE); console.log(`Revoked ${db.devices.revoke(rest[0])} device(s).`); return; default: console.log(USAGE); } } /** Exchanges a T3 pairing credential for a bearer session and saves the host. */ async function hostAdd(args: string[]) { const positional: string[] = []; const altUrls: string[] = []; let label: string | undefined; for (let index = 0; index < args.length; index++) { const arg = args[index]!; if (arg === "--label") label = args[++index]; else if (arg === "--alt-url") altUrls.push(new URL(args[++index] ?? "").origin); else positional.push(arg); } const [rawUrl, credential] = positional; if (!rawUrl || !credential) throw new Error(USAGE); const url = new URL(rawUrl).origin; const descriptor = await getJson(`${url}/.well-known/t3/environment`); if ((descriptor.orchestrationProtocolVersion ?? 1) !== ORCHESTRATION_PROTOCOL_VERSION) { throw new Error(`${url} speaks orchestration protocol ${descriptor.orchestrationProtocolVersion ?? 1}, this gateway speaks ${ORCHESTRATION_PROTOCOL_VERSION}.`); } const response = await fetch(`${url}/oauth/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token: credential, subject_token_type: "urn:t3:params:oauth:token-type:environment-bootstrap", requested_token_type: "urn:ietf:params:oauth:token-type:access_token", scope: "orchestration:read orchestration:operate", client_label: "WristCode gateway", client_device_type: "bot", }), }); if (!response.ok) throw new Error(`${url} refused the pairing credential (${response.status}): ${await response.text()}`); const token = (await response.json()) as { access_token: string; expires_in?: number; scope?: string }; const scopes = token.scope?.split(" ") ?? []; if (!scopes.includes("orchestration:read") || !scopes.includes("orchestration:operate")) { throw new Error(`The credential only granted "${token.scope}". Make one with orchestration:read and orchestration:operate.`); } db.hosts.upsert({ id: descriptor.environmentId, label: label ?? descriptor.label, url, alt_urls: altUrls.length > 0 ? JSON.stringify(altUrls) : null, environment_id: descriptor.environmentId, token: token.access_token, token_expires_at: token.expires_in ? new Date(Date.now() + token.expires_in * 1000).toISOString() : null, }); console.log(`Added ${label ?? descriptor.label} (T3 ${descriptor.serverVersion}). The gateway connects within 15 seconds.`); } async function getJson(url: string): Promise { const response = await fetch(url); if (!response.ok) throw new Error(`${url} returned ${response.status}`); return response.json() as Promise; }