Gateway: T3 RPC client, host connection, awareness port, storage, API contract
This commit is contained in:
1 parent
0fd556e313
commit
972f6e4be5
13 files changed
+1332
-9
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// 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;
|
||||
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);
|
||||
|
||||
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, environment_id, token, token_expires_at, created_at)
|
||||
values ($id, $label, $url, $environment_id, $token, $token_expires_at, $created_at)
|
||||
on conflict(environment_id) do update set
|
||||
label = excluded.label, url = excluded.url, 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>;
|
||||
Reference in new issue
Block a user