Files
wristcode/gateway/src/db.ts
T
gib 42b6a4920c
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s
Gateway: fallback routes per host, for the laptop's VPN address
2026-11-01 13:20:02 -05:00

145 lines
5.2 KiB
TypeScript

// SQLite state: the T3 hosts the gateway connects to, the watches paired with it, and pending
// pairing codes. The CLI and the server open the same file, and the server rereads `hosts` so a
// `wristcode host add` takes effect without a restart.
import { Database } from "bun:sqlite";
export interface HostRow {
id: string;
label: string;
url: string;
/** Fallback routes to the same server, as a JSON array, tried in order after `url`. */
alt_urls: string | null;
environment_id: string;
token: string;
token_expires_at: string | null;
created_at: string;
}
export interface DeviceRow {
id: string;
name: string;
token_hash: string;
apns_token: string | null;
apns_env: "sandbox" | "production" | null;
created_at: string;
last_seen_at: string | null;
revoked_at: string | null;
}
const SCHEMA = `
create table if not exists hosts (
id text primary key,
label text not null,
url text not null unique,
environment_id text not null unique,
token text not null,
token_expires_at text,
created_at text not null
);
create table if not exists devices (
id text primary key,
name text not null,
token_hash text not null unique,
apns_token text,
apns_env text,
created_at text not null,
last_seen_at text,
revoked_at text
);
create table if not exists pairing_codes (
code_hash text primary key,
expires_at text not null,
used_at text
);
`;
export const sha256 = (value: string) => new Bun.CryptoHasher("sha256").update(value).digest("hex");
const now = () => new Date().toISOString();
export function openDb(path: string) {
const db = new Database(path, { create: true, strict: true });
db.run("pragma journal_mode = wal");
db.run("pragma busy_timeout = 5000");
db.run(SCHEMA);
const hostColumns = db.query<{ name: string }, []>("pragma table_info(hosts)").all().map((column) => column.name);
if (!hostColumns.includes("alt_urls")) db.run("alter table hosts add column alt_urls text");
return {
hosts: {
list: () => db.query<HostRow, []>("select * from hosts order by created_at").all(),
upsert(host: Omit<HostRow, "created_at">) {
db.query(
`insert into hosts (id, label, url, alt_urls, environment_id, token, token_expires_at, created_at)
values ($id, $label, $url, $alt_urls, $environment_id, $token, $token_expires_at, $created_at)
on conflict(environment_id) do update set
label = excluded.label, url = excluded.url, alt_urls = excluded.alt_urls,
token = excluded.token, token_expires_at = excluded.token_expires_at`,
).run({ ...host, created_at: now() });
},
remove: (idOrLabel: string) =>
db.query("delete from hosts where id = $v or label = $v").run({ v: idOrLabel }).changes > 0,
},
devices: {
list: () => db.query<DeviceRow, []>("select * from devices order by created_at").all(),
active: () => db.query<DeviceRow, []>("select * from devices where revoked_at is null").all(),
create(name: string, token: string): string {
const id = crypto.randomUUID();
db.query(
"insert into devices (id, name, token_hash, created_at) values ($id, $name, $hash, $created_at)",
).run({ id, name, hash: sha256(token), created_at: now() });
return id;
},
/** The active device that owns this bearer token, marking it seen. */
authenticate(token: string): DeviceRow | null {
const device = db
.query<DeviceRow, { hash: string }>("select * from devices where token_hash = $hash and revoked_at is null")
.get({ hash: sha256(token) });
if (device) db.query("update devices set last_seen_at = $at where id = $id").run({ at: now(), id: device.id });
return device;
},
setPush(id: string, apnsToken: string, apnsEnv: "sandbox" | "production") {
db.query("update devices set apns_token = $apnsToken, apns_env = $apnsEnv where id = $id").run({
id,
apnsToken,
apnsEnv,
});
},
clearPush: (apnsToken: string) =>
db.query("update devices set apns_token = null where apns_token = $apnsToken").run({ apnsToken }),
revoke: (idOrName: string) =>
db.query("update devices set revoked_at = $at where (id = $v or name = $v) and revoked_at is null").run({
at: now(),
v: idOrName,
}).changes,
},
pairing: {
create(code: string, ttlMs: number) {
db.query("insert into pairing_codes (code_hash, expires_at) values ($hash, $expires_at)").run({
hash: sha256(code),
expires_at: new Date(Date.now() + ttlMs).toISOString(),
});
},
/** Marks the code used and returns true if it was valid. */
redeem(code: string): boolean {
return (
db
.query(
"update pairing_codes set used_at = $at where code_hash = $hash and used_at is null and expires_at > $at",
)
.run({ hash: sha256(code), at: now() }).changes > 0
);
},
/** Voids every open code, after too many wrong guesses. */
voidAll: () => db.query("update pairing_codes set used_at = $at where used_at is null").run({ at: now() }),
},
close: () => db.close(),
};
}
export type Db = ReturnType<typeof openDb>;