145 lines
5.2 KiB
TypeScript
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>;
|