Gateway: watch API, pairing, notifications, APNs sender, CLI, tests
This commit is contained in:
1 parent
972f6e4be5
commit
6ca098b701
12 files changed
+1297
-1
No files matched your search
Executable
+118
@@ -0,0 +1,118 @@
|
||||
#!/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: `docker exec -it wristcode-gateway wristcode <command>`.
|
||||
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { openDb } from "./db.ts";
|
||||
import { ORCHESTRATION_PROTOCOL_VERSION, type EnvironmentDescriptor } from "./t3/types.ts";
|
||||
|
||||
const USAGE = `wristcode <command>
|
||||
|
||||
host add <url> <pairing-credential> [--label <name>]
|
||||
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.
|
||||
host list
|
||||
host remove <id or label>
|
||||
pair
|
||||
Print a 6 digit code to enter on the watch. Good for 5 minutes.
|
||||
device list
|
||||
device revoke <id or name>`;
|
||||
|
||||
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${host.url}\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 labelFlag = args.indexOf("--label");
|
||||
const label = labelFlag === -1 ? undefined : args[labelFlag + 1];
|
||||
const [rawUrl, credential] = args.filter((_, index) => labelFlag === -1 || (index !== labelFlag && index !== labelFlag + 1));
|
||||
if (!rawUrl || !credential) throw new Error(USAGE);
|
||||
const url = new URL(rawUrl).origin;
|
||||
|
||||
const descriptor = await getJson<EnvironmentDescriptor>(`${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,
|
||||
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<T>(url: string): Promise<T> {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`${url} returned ${response.status}`);
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
Reference in new issue
Block a user