feat: add SQLite and MySQL database support with setup wizard selection (RCA-21)
Replace in-memory storage with a database abstraction layer supporting SQLite and MySQL. Users choose their preferred database during the first-time setup wizard. The server persists the database config to data/db-config.json and loads it automatically on restart. - Add database abstraction interfaces (ICookieStore, IDeviceStore, IAgentStore, IAdminStore) - Implement SQLite driver using better-sqlite3 with WAL mode - Implement MySQL driver using mysql2 connection pooling - Keep memory-backed driver for backwards compatibility and testing - Add database selection step (step 2) to the setup wizard UI - Update setup API to accept dbConfig and initialize the chosen database - Update RelayServer to use async store interfaces with runtime store replacement Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
58
src/relay/db/index.ts
Normal file
58
src/relay/db/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { DataStores, DbConfig } from "./types.js";
|
||||
import { createMemoryStores } from "./memory.js";
|
||||
import { createSqliteStores } from "./sqlite.js";
|
||||
import { createMysqlStores } from "./mysql.js";
|
||||
|
||||
export type { DataStores, DbConfig, DbType, SqliteConfig, MysqlConfig, ICookieStore, IDeviceStore, IAgentStore, IAdminStore } from "./types.js";
|
||||
|
||||
const DEFAULT_CONFIG_DIR = path.join(process.cwd(), "data");
|
||||
const CONFIG_FILE = "db-config.json";
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return path.join(DEFAULT_CONFIG_DIR, CONFIG_FILE);
|
||||
}
|
||||
|
||||
/** Load saved database config, or null if not yet configured. */
|
||||
export function loadDbConfig(): DbConfig | null {
|
||||
const configPath = getConfigPath();
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
try {
|
||||
const raw = fs.readFileSync(configPath, "utf-8");
|
||||
return JSON.parse(raw) as DbConfig;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Save database config to disk. */
|
||||
export function saveDbConfig(config: DbConfig): void {
|
||||
const configPath = getConfigPath();
|
||||
const dir = path.dirname(configPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
/** Create data stores from config. If no config exists, returns null (setup required). */
|
||||
export async function createStores(config: DbConfig): Promise<DataStores> {
|
||||
switch (config.type) {
|
||||
case "sqlite": {
|
||||
// Ensure the directory for the SQLite file exists
|
||||
const dir = path.dirname(config.path);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
return createSqliteStores(config);
|
||||
}
|
||||
case "mysql":
|
||||
return createMysqlStores(config);
|
||||
default:
|
||||
throw new Error(`Unknown database type: ${(config as DbConfig).type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Create in-memory stores (for backwards compatibility / testing). */
|
||||
export { createMemoryStores } from "./memory.js";
|
||||
126
src/relay/db/memory.ts
Normal file
126
src/relay/db/memory.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { CookieBlobStore } from "../store.js";
|
||||
import { DeviceRegistry, AgentRegistry } from "../tokens.js";
|
||||
import { AdminStore } from "../admin/auth.js";
|
||||
import type {
|
||||
ICookieStore,
|
||||
IDeviceStore,
|
||||
IAgentStore,
|
||||
IAdminStore,
|
||||
DataStores,
|
||||
} from "./types.js";
|
||||
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
|
||||
import type { AdminSettings } from "../admin/auth.js";
|
||||
|
||||
class MemoryCookieStore implements ICookieStore {
|
||||
private inner = new CookieBlobStore();
|
||||
|
||||
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
|
||||
return this.inner.upsert(blob);
|
||||
}
|
||||
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
|
||||
return this.inner.delete(deviceId, domain, cookieName, path);
|
||||
}
|
||||
async deleteById(id: string): Promise<boolean> {
|
||||
return this.inner.deleteById(id);
|
||||
}
|
||||
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
return this.inner.getByDevice(deviceId, domain);
|
||||
}
|
||||
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
return this.inner.getByDevices(deviceIds, domain);
|
||||
}
|
||||
async getAll(): Promise<EncryptedCookieBlob[]> {
|
||||
return this.inner.getAll();
|
||||
}
|
||||
async getById(id: string): Promise<EncryptedCookieBlob | null> {
|
||||
return this.inner.getById(id);
|
||||
}
|
||||
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
|
||||
return this.inner.getUpdatedSince(deviceIds, since);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryDeviceStore implements IDeviceStore {
|
||||
private inner = new DeviceRegistry();
|
||||
|
||||
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
|
||||
return this.inner.register(deviceId, name, platform, encPub);
|
||||
}
|
||||
async getByToken(token: string): Promise<DeviceInfo | null> {
|
||||
return this.inner.getByToken(token);
|
||||
}
|
||||
async getById(deviceId: string): Promise<DeviceInfo | null> {
|
||||
return this.inner.getById(deviceId);
|
||||
}
|
||||
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
|
||||
this.inner.addPairing(deviceIdA, deviceIdB);
|
||||
}
|
||||
async getPairedDevices(deviceId: string): Promise<string[]> {
|
||||
return this.inner.getPairedDevices(deviceId);
|
||||
}
|
||||
async getPairingGroup(deviceId: string): Promise<string[]> {
|
||||
return this.inner.getPairingGroup(deviceId);
|
||||
}
|
||||
async listAll(): Promise<DeviceInfo[]> {
|
||||
return this.inner.listAll();
|
||||
}
|
||||
async revoke(deviceId: string): Promise<boolean> {
|
||||
return this.inner.revoke(deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAgentStore implements IAgentStore {
|
||||
private inner = new AgentRegistry();
|
||||
|
||||
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
|
||||
return this.inner.create(name, encPub, allowedDomains);
|
||||
}
|
||||
async getByToken(token: string): Promise<AgentToken | null> {
|
||||
return this.inner.getByToken(token);
|
||||
}
|
||||
async grantAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
this.inner.grantAccess(agentId, deviceId);
|
||||
}
|
||||
async getAccessibleDevices(agentId: string): Promise<string[]> {
|
||||
return this.inner.getAccessibleDevices(agentId);
|
||||
}
|
||||
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
this.inner.revokeAccess(agentId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoryAdminStore implements IAdminStore {
|
||||
private inner = new AdminStore();
|
||||
|
||||
get isSetUp(): boolean {
|
||||
return this.inner.isSetUp;
|
||||
}
|
||||
async setup(username: string, password: string): Promise<void> {
|
||||
return this.inner.setup(username, password);
|
||||
}
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
return this.inner.login(username, password);
|
||||
}
|
||||
verifyToken(token: string): { sub: string; role: string } {
|
||||
return this.inner.verifyToken(token);
|
||||
}
|
||||
getUser(): { username: string; createdAt: string } | null {
|
||||
return this.inner.getUser();
|
||||
}
|
||||
getSettings(): AdminSettings {
|
||||
return this.inner.getSettings();
|
||||
}
|
||||
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
|
||||
return this.inner.updateSettings(patch);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryStores(): DataStores {
|
||||
return {
|
||||
cookieStore: new MemoryCookieStore(),
|
||||
deviceStore: new MemoryDeviceStore(),
|
||||
agentStore: new MemoryAgentStore(),
|
||||
adminStore: new MemoryAdminStore(),
|
||||
async close() { /* no-op */ },
|
||||
};
|
||||
}
|
||||
530
src/relay/db/mysql.ts
Normal file
530
src/relay/db/mysql.ts
Normal file
@@ -0,0 +1,530 @@
|
||||
import mysql from "mysql2/promise";
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import sodium from "sodium-native";
|
||||
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
|
||||
import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js";
|
||||
import type { AdminSettings } from "../admin/auth.js";
|
||||
import type {
|
||||
ICookieStore,
|
||||
IDeviceStore,
|
||||
IAgentStore,
|
||||
IAdminStore,
|
||||
DataStores,
|
||||
MysqlConfig,
|
||||
} from "./types.js";
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_COST = 16384;
|
||||
const SCRYPT_BLOCK_SIZE = 8;
|
||||
const SCRYPT_PARALLELISM = 1;
|
||||
|
||||
const DEFAULT_SETTINGS: AdminSettings = {
|
||||
syncIntervalMs: 30_000,
|
||||
maxDevices: 10,
|
||||
autoSync: true,
|
||||
theme: "system",
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
const buf = Buffer.alloc(16);
|
||||
sodium.randombytes_buf(buf);
|
||||
return buf.toString("hex");
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
const buf = Buffer.alloc(32);
|
||||
sodium.randombytes_buf(buf);
|
||||
return "cb_" + buf.toString("hex");
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(
|
||||
password,
|
||||
Buffer.from(salt, "hex"),
|
||||
SCRYPT_KEYLEN,
|
||||
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
|
||||
(err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString("hex"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function initSchema(pool: mysql.Pool): Promise<void> {
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS cookies (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
device_id VARCHAR(128) NOT NULL,
|
||||
domain VARCHAR(255) NOT NULL,
|
||||
cookie_name VARCHAR(255) NOT NULL,
|
||||
path VARCHAR(512) NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
ciphertext LONGTEXT NOT NULL,
|
||||
lamport_ts BIGINT NOT NULL,
|
||||
updated_at VARCHAR(64) NOT NULL,
|
||||
UNIQUE KEY uk_cookie (device_id, domain, cookie_name, path(191)),
|
||||
INDEX idx_cookies_device (device_id),
|
||||
INDEX idx_cookies_domain (domain),
|
||||
INDEX idx_cookies_updated (updated_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id VARCHAR(128) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
platform VARCHAR(64) NOT NULL,
|
||||
enc_pub VARCHAR(128) NOT NULL,
|
||||
token VARCHAR(128) NOT NULL,
|
||||
created_at VARCHAR(64) NOT NULL,
|
||||
UNIQUE KEY uk_device_token (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS pairings (
|
||||
device_id_a VARCHAR(128) NOT NULL,
|
||||
device_id_b VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (device_id_a, device_id_b)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
token VARCHAR(128) NOT NULL,
|
||||
enc_pub VARCHAR(128) NOT NULL,
|
||||
allowed_domains TEXT NOT NULL,
|
||||
created_at VARCHAR(64) NOT NULL,
|
||||
UNIQUE KEY uk_agent_token (token)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS agent_device_access (
|
||||
agent_id VARCHAR(64) NOT NULL,
|
||||
device_id VARCHAR(128) NOT NULL,
|
||||
PRIMARY KEY (agent_id, device_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
username VARCHAR(255) PRIMARY KEY,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
salt VARCHAR(64) NOT NULL,
|
||||
created_at VARCHAR(64) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
\`key\` VARCHAR(64) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
|
||||
await pool.execute(`
|
||||
CREATE TABLE IF NOT EXISTS admin_meta (
|
||||
\`key\` VARCHAR(64) PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
`);
|
||||
}
|
||||
|
||||
function toBlob(row: Record<string, unknown>): EncryptedCookieBlob {
|
||||
return {
|
||||
id: row.id as string,
|
||||
deviceId: row.device_id as string,
|
||||
domain: row.domain as string,
|
||||
cookieName: row.cookie_name as string,
|
||||
path: row.path as string,
|
||||
nonce: row.nonce as string,
|
||||
ciphertext: row.ciphertext as string,
|
||||
lamportTs: Number(row.lamport_ts),
|
||||
updatedAt: row.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
function toDevice(row: Record<string, unknown>): DeviceInfo {
|
||||
return {
|
||||
deviceId: row.device_id as string,
|
||||
name: row.name as string,
|
||||
platform: row.platform as string,
|
||||
encPub: row.enc_pub as string,
|
||||
token: row.token as string,
|
||||
createdAt: row.created_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
function toAgent(row: Record<string, unknown>): AgentToken {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
token: row.token as string,
|
||||
encPub: row.enc_pub as string,
|
||||
allowedDomains: JSON.parse(row.allowed_domains as string),
|
||||
createdAt: row.created_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
// --- MySQL Cookie Store ---
|
||||
|
||||
class MysqlCookieStore implements ICookieStore {
|
||||
constructor(private pool: mysql.Pool) {}
|
||||
|
||||
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
|
||||
const [rows] = await this.pool.execute(
|
||||
"SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
|
||||
[blob.deviceId, blob.domain, blob.cookieName, blob.path],
|
||||
);
|
||||
const existing = (rows as Record<string, unknown>[])[0];
|
||||
|
||||
if (existing && Number(existing.lamport_ts) >= blob.lamportTs) {
|
||||
return toBlob(existing);
|
||||
}
|
||||
|
||||
const id = existing ? existing.id as string : generateId();
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
await this.pool.execute(
|
||||
`INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
nonce = VALUES(nonce),
|
||||
ciphertext = VALUES(ciphertext),
|
||||
lamport_ts = VALUES(lamport_ts),
|
||||
updated_at = VALUES(updated_at)`,
|
||||
[id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt],
|
||||
);
|
||||
|
||||
// Enforce per-device limit
|
||||
const [countRows] = await this.pool.execute(
|
||||
"SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?",
|
||||
[blob.deviceId],
|
||||
);
|
||||
const count = Number((countRows as Record<string, unknown>[])[0].cnt);
|
||||
if (count > MAX_STORED_COOKIES_PER_DEVICE) {
|
||||
await this.pool.execute(
|
||||
"DELETE FROM cookies WHERE id IN (SELECT id FROM (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?) AS tmp)",
|
||||
[blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE],
|
||||
);
|
||||
}
|
||||
|
||||
return { ...blob, id, updatedAt };
|
||||
}
|
||||
|
||||
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
|
||||
const [result] = await this.pool.execute(
|
||||
"DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
|
||||
[deviceId, domain, cookieName, path],
|
||||
);
|
||||
return (result as mysql.ResultSetHeader).affectedRows > 0;
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<boolean> {
|
||||
const [result] = await this.pool.execute("DELETE FROM cookies WHERE id = ?", [id]);
|
||||
return (result as mysql.ResultSetHeader).affectedRows > 0;
|
||||
}
|
||||
|
||||
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (domain) {
|
||||
const [rows] = await this.pool.execute(
|
||||
"SELECT * FROM cookies WHERE device_id = ? AND domain = ?",
|
||||
[deviceId, domain],
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE device_id = ?", [deviceId]);
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (deviceIds.length === 0) return [];
|
||||
const placeholders = deviceIds.map(() => "?").join(",");
|
||||
if (domain) {
|
||||
const [rows] = await this.pool.execute(
|
||||
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`,
|
||||
[...deviceIds, domain],
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
const [rows] = await this.pool.execute(
|
||||
`SELECT * FROM cookies WHERE device_id IN (${placeholders})`,
|
||||
deviceIds,
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getAll(): Promise<EncryptedCookieBlob[]> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM cookies");
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<EncryptedCookieBlob | null> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM cookies WHERE id = ?", [id]);
|
||||
const row = (rows as Record<string, unknown>[])[0];
|
||||
return row ? toBlob(row) : null;
|
||||
}
|
||||
|
||||
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (deviceIds.length === 0) return [];
|
||||
const placeholders = deviceIds.map(() => "?").join(",");
|
||||
const [rows] = await this.pool.execute(
|
||||
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`,
|
||||
[...deviceIds, since],
|
||||
);
|
||||
return (rows as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MySQL Device Store ---
|
||||
|
||||
class MysqlDeviceStore implements IDeviceStore {
|
||||
constructor(private pool: mysql.Pool) {}
|
||||
|
||||
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]);
|
||||
const existing = (rows as Record<string, unknown>[])[0];
|
||||
if (existing) return toDevice(existing);
|
||||
|
||||
const token = generateToken();
|
||||
const createdAt = new Date().toISOString();
|
||||
await this.pool.execute(
|
||||
"INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[deviceId, name, platform, encPub, token, createdAt],
|
||||
);
|
||||
return { deviceId, name, platform, encPub, token, createdAt };
|
||||
}
|
||||
|
||||
async getByToken(token: string): Promise<DeviceInfo | null> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE token = ?", [token]);
|
||||
const row = (rows as Record<string, unknown>[])[0];
|
||||
return row ? toDevice(row) : null;
|
||||
}
|
||||
|
||||
async getById(deviceId: string): Promise<DeviceInfo | null> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM devices WHERE device_id = ?", [deviceId]);
|
||||
const row = (rows as Record<string, unknown>[])[0];
|
||||
return row ? toDevice(row) : null;
|
||||
}
|
||||
|
||||
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
|
||||
await this.pool.execute(
|
||||
"INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
|
||||
[deviceIdA, deviceIdB],
|
||||
);
|
||||
await this.pool.execute(
|
||||
"INSERT IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
|
||||
[deviceIdB, deviceIdA],
|
||||
);
|
||||
}
|
||||
|
||||
async getPairedDevices(deviceId: string): Promise<string[]> {
|
||||
const [rows] = await this.pool.execute(
|
||||
"SELECT device_id_b FROM pairings WHERE device_id_a = ?",
|
||||
[deviceId],
|
||||
);
|
||||
return (rows as { device_id_b: string }[]).map((r) => r.device_id_b);
|
||||
}
|
||||
|
||||
async getPairingGroup(deviceId: string): Promise<string[]> {
|
||||
const paired = await this.getPairedDevices(deviceId);
|
||||
return [deviceId, ...paired];
|
||||
}
|
||||
|
||||
async listAll(): Promise<DeviceInfo[]> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM devices");
|
||||
return (rows as Record<string, unknown>[]).map(toDevice);
|
||||
}
|
||||
|
||||
async revoke(deviceId: string): Promise<boolean> {
|
||||
const [result] = await this.pool.execute("DELETE FROM devices WHERE device_id = ?", [deviceId]);
|
||||
if ((result as mysql.ResultSetHeader).affectedRows === 0) return false;
|
||||
await this.pool.execute(
|
||||
"DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?",
|
||||
[deviceId, deviceId],
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- MySQL Agent Store ---
|
||||
|
||||
class MysqlAgentStore implements IAgentStore {
|
||||
constructor(private pool: mysql.Pool) {}
|
||||
|
||||
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
|
||||
const id = generateId();
|
||||
const token = generateToken();
|
||||
const createdAt = new Date().toISOString();
|
||||
await this.pool.execute(
|
||||
"INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[id, name, token, encPub, JSON.stringify(allowedDomains), createdAt],
|
||||
);
|
||||
return { id, name, token, encPub, allowedDomains, createdAt };
|
||||
}
|
||||
|
||||
async getByToken(token: string): Promise<AgentToken | null> {
|
||||
const [rows] = await this.pool.execute("SELECT * FROM agents WHERE token = ?", [token]);
|
||||
const row = (rows as Record<string, unknown>[])[0];
|
||||
return row ? toAgent(row) : null;
|
||||
}
|
||||
|
||||
async grantAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
await this.pool.execute(
|
||||
"INSERT IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)",
|
||||
[agentId, deviceId],
|
||||
);
|
||||
}
|
||||
|
||||
async getAccessibleDevices(agentId: string): Promise<string[]> {
|
||||
const [rows] = await this.pool.execute(
|
||||
"SELECT device_id FROM agent_device_access WHERE agent_id = ?",
|
||||
[agentId],
|
||||
);
|
||||
return (rows as { device_id: string }[]).map((r) => r.device_id);
|
||||
}
|
||||
|
||||
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
await this.pool.execute(
|
||||
"DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?",
|
||||
[agentId, deviceId],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- MySQL Admin Store ---
|
||||
|
||||
class MysqlAdminStore implements IAdminStore {
|
||||
private jwtSecret!: string;
|
||||
private _settings: AdminSettings = { ...DEFAULT_SETTINGS };
|
||||
private _isSetUp: boolean = false;
|
||||
private _user: { username: string; createdAt: string } | null = null;
|
||||
|
||||
constructor(private pool: mysql.Pool) {}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
// Load or generate JWT secret
|
||||
const [metaRows] = await this.pool.execute(
|
||||
"SELECT value FROM admin_meta WHERE `key` = 'jwt_secret'",
|
||||
);
|
||||
const meta = (metaRows as { value: string }[])[0];
|
||||
if (meta) {
|
||||
this.jwtSecret = meta.value;
|
||||
} else {
|
||||
this.jwtSecret = crypto.randomBytes(32).toString("hex");
|
||||
await this.pool.execute(
|
||||
"INSERT INTO admin_meta (`key`, value) VALUES ('jwt_secret', ?)",
|
||||
[this.jwtSecret],
|
||||
);
|
||||
}
|
||||
|
||||
// Load settings
|
||||
const [settingsRows] = await this.pool.execute(
|
||||
"SELECT value FROM admin_settings WHERE `key` = 'settings'",
|
||||
);
|
||||
const settingsRow = (settingsRows as { value: string }[])[0];
|
||||
if (settingsRow) {
|
||||
Object.assign(this._settings, JSON.parse(settingsRow.value));
|
||||
}
|
||||
|
||||
// Check setup status
|
||||
const [countRows] = await this.pool.execute("SELECT COUNT(*) as cnt FROM admin_users");
|
||||
this._isSetUp = Number((countRows as { cnt: number }[])[0].cnt) > 0;
|
||||
|
||||
if (this._isSetUp) {
|
||||
const [userRows] = await this.pool.execute(
|
||||
"SELECT username, created_at FROM admin_users LIMIT 1",
|
||||
);
|
||||
const userRow = (userRows as Record<string, unknown>[])[0];
|
||||
if (userRow) {
|
||||
this._user = { username: userRow.username as string, createdAt: userRow.created_at as string };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get isSetUp(): boolean {
|
||||
return this._isSetUp;
|
||||
}
|
||||
|
||||
async setup(username: string, password: string): Promise<void> {
|
||||
if (this._isSetUp) throw new Error("Already configured");
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = await hashPassword(password, salt);
|
||||
const createdAt = new Date().toISOString();
|
||||
await this.pool.execute(
|
||||
"INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)",
|
||||
[username, hash, salt, createdAt],
|
||||
);
|
||||
this._isSetUp = true;
|
||||
this._user = { username, createdAt };
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
const [rows] = await this.pool.execute(
|
||||
"SELECT * FROM admin_users WHERE username = ?",
|
||||
[username],
|
||||
);
|
||||
const user = (rows as Record<string, unknown>[])[0];
|
||||
if (!user) throw new Error("Invalid credentials");
|
||||
const hash = await hashPassword(password, user.salt as string);
|
||||
if (hash !== user.password_hash) throw new Error("Invalid credentials");
|
||||
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
|
||||
}
|
||||
|
||||
verifyToken(token: string): { sub: string; role: string } {
|
||||
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
|
||||
}
|
||||
|
||||
getUser(): { username: string; createdAt: string } | null {
|
||||
return this._user;
|
||||
}
|
||||
|
||||
getSettings(): AdminSettings {
|
||||
return { ...this._settings };
|
||||
}
|
||||
|
||||
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
|
||||
Object.assign(this._settings, patch);
|
||||
// Fire and forget the DB write
|
||||
this.pool.execute(
|
||||
"INSERT INTO admin_settings (`key`, value) VALUES ('settings', ?) ON DUPLICATE KEY UPDATE value = VALUES(value)",
|
||||
[JSON.stringify(this._settings)],
|
||||
);
|
||||
return { ...this._settings };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Factory ---
|
||||
|
||||
export async function createMysqlStores(config: MysqlConfig): Promise<DataStores> {
|
||||
const pool = mysql.createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
user: config.user,
|
||||
password: config.password,
|
||||
database: config.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
});
|
||||
|
||||
await initSchema(pool);
|
||||
|
||||
const adminStore = new MysqlAdminStore(pool);
|
||||
await adminStore.initialize();
|
||||
|
||||
return {
|
||||
cookieStore: new MysqlCookieStore(pool),
|
||||
deviceStore: new MysqlDeviceStore(pool),
|
||||
agentStore: new MysqlAgentStore(pool),
|
||||
adminStore,
|
||||
async close() {
|
||||
await pool.end();
|
||||
},
|
||||
};
|
||||
}
|
||||
431
src/relay/db/sqlite.ts
Normal file
431
src/relay/db/sqlite.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import Database from "better-sqlite3";
|
||||
import crypto from "node:crypto";
|
||||
import jwt from "jsonwebtoken";
|
||||
import sodium from "sodium-native";
|
||||
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
|
||||
import { MAX_STORED_COOKIES_PER_DEVICE } from "../../protocol/spec.js";
|
||||
import type { AdminSettings } from "../admin/auth.js";
|
||||
import type {
|
||||
ICookieStore,
|
||||
IDeviceStore,
|
||||
IAgentStore,
|
||||
IAdminStore,
|
||||
DataStores,
|
||||
SqliteConfig,
|
||||
} from "./types.js";
|
||||
|
||||
const SCRYPT_KEYLEN = 64;
|
||||
const SCRYPT_COST = 16384;
|
||||
const SCRYPT_BLOCK_SIZE = 8;
|
||||
const SCRYPT_PARALLELISM = 1;
|
||||
|
||||
const DEFAULT_SETTINGS: AdminSettings = {
|
||||
syncIntervalMs: 30_000,
|
||||
maxDevices: 10,
|
||||
autoSync: true,
|
||||
theme: "system",
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
const buf = Buffer.alloc(16);
|
||||
sodium.randombytes_buf(buf);
|
||||
return buf.toString("hex");
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
const buf = Buffer.alloc(32);
|
||||
sodium.randombytes_buf(buf);
|
||||
return "cb_" + buf.toString("hex");
|
||||
}
|
||||
|
||||
function hashPassword(password: string, salt: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(
|
||||
password,
|
||||
Buffer.from(salt, "hex"),
|
||||
SCRYPT_KEYLEN,
|
||||
{ N: SCRYPT_COST, r: SCRYPT_BLOCK_SIZE, p: SCRYPT_PARALLELISM },
|
||||
(err, derived) => {
|
||||
if (err) reject(err);
|
||||
else resolve(derived.toString("hex"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function initSchema(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS cookies (
|
||||
id TEXT PRIMARY KEY,
|
||||
device_id TEXT NOT NULL,
|
||||
domain TEXT NOT NULL,
|
||||
cookie_name TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
ciphertext TEXT NOT NULL,
|
||||
lamport_ts INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(device_id, domain, cookie_name, path)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cookies_device ON cookies(device_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cookies_domain ON cookies(domain);
|
||||
CREATE INDEX IF NOT EXISTS idx_cookies_updated ON cookies(updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
device_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
platform TEXT NOT NULL,
|
||||
enc_pub TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pairings (
|
||||
device_id_a TEXT NOT NULL,
|
||||
device_id_b TEXT NOT NULL,
|
||||
PRIMARY KEY (device_id_a, device_id_b)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
enc_pub TEXT NOT NULL,
|
||||
allowed_domains TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_device_access (
|
||||
agent_id TEXT NOT NULL,
|
||||
device_id TEXT NOT NULL,
|
||||
PRIMARY KEY (agent_id, device_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password_hash TEXT NOT NULL,
|
||||
salt TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function toBlob(row: Record<string, unknown>): EncryptedCookieBlob {
|
||||
return {
|
||||
id: row.id as string,
|
||||
deviceId: row.device_id as string,
|
||||
domain: row.domain as string,
|
||||
cookieName: row.cookie_name as string,
|
||||
path: row.path as string,
|
||||
nonce: row.nonce as string,
|
||||
ciphertext: row.ciphertext as string,
|
||||
lamportTs: row.lamport_ts as number,
|
||||
updatedAt: row.updated_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
function toDevice(row: Record<string, unknown>): DeviceInfo {
|
||||
return {
|
||||
deviceId: row.device_id as string,
|
||||
name: row.name as string,
|
||||
platform: row.platform as string,
|
||||
encPub: row.enc_pub as string,
|
||||
token: row.token as string,
|
||||
createdAt: row.created_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
function toAgent(row: Record<string, unknown>): AgentToken {
|
||||
return {
|
||||
id: row.id as string,
|
||||
name: row.name as string,
|
||||
token: row.token as string,
|
||||
encPub: row.enc_pub as string,
|
||||
allowedDomains: JSON.parse(row.allowed_domains as string),
|
||||
createdAt: row.created_at as string,
|
||||
};
|
||||
}
|
||||
|
||||
// --- SQLite Cookie Store ---
|
||||
|
||||
class SqliteCookieStore implements ICookieStore {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
async upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob> {
|
||||
const existing = this.db.prepare(
|
||||
"SELECT * FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
|
||||
).get(blob.deviceId, blob.domain, blob.cookieName, blob.path) as Record<string, unknown> | undefined;
|
||||
|
||||
if (existing && (existing.lamport_ts as number) >= blob.lamportTs) {
|
||||
return toBlob(existing);
|
||||
}
|
||||
|
||||
const id = existing ? existing.id as string : generateId();
|
||||
const updatedAt = new Date().toISOString();
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO cookies (id, device_id, domain, cookie_name, path, nonce, ciphertext, lamport_ts, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(device_id, domain, cookie_name, path) DO UPDATE SET
|
||||
nonce = excluded.nonce,
|
||||
ciphertext = excluded.ciphertext,
|
||||
lamport_ts = excluded.lamport_ts,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(id, blob.deviceId, blob.domain, blob.cookieName, blob.path, blob.nonce, blob.ciphertext, blob.lamportTs, updatedAt);
|
||||
|
||||
// Enforce per-device limit
|
||||
const count = (this.db.prepare("SELECT COUNT(*) as cnt FROM cookies WHERE device_id = ?").get(blob.deviceId) as { cnt: number }).cnt;
|
||||
if (count > MAX_STORED_COOKIES_PER_DEVICE) {
|
||||
this.db.prepare(
|
||||
"DELETE FROM cookies WHERE id IN (SELECT id FROM cookies WHERE device_id = ? ORDER BY updated_at ASC LIMIT ?)",
|
||||
).run(blob.deviceId, count - MAX_STORED_COOKIES_PER_DEVICE);
|
||||
}
|
||||
|
||||
return { ...blob, id, updatedAt };
|
||||
}
|
||||
|
||||
async delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean> {
|
||||
const result = this.db.prepare(
|
||||
"DELETE FROM cookies WHERE device_id = ? AND domain = ? AND cookie_name = ? AND path = ?",
|
||||
).run(deviceId, domain, cookieName, path);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async deleteById(id: string): Promise<boolean> {
|
||||
const result = this.db.prepare("DELETE FROM cookies WHERE id = ?").run(id);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
async getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (domain) {
|
||||
return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ? AND domain = ?").all(deviceId, domain) as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
return (this.db.prepare("SELECT * FROM cookies WHERE device_id = ?").all(deviceId) as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (deviceIds.length === 0) return [];
|
||||
const placeholders = deviceIds.map(() => "?").join(",");
|
||||
if (domain) {
|
||||
return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND domain = ?`).all(...deviceIds, domain) as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
return (this.db.prepare(`SELECT * FROM cookies WHERE device_id IN (${placeholders})`).all(...deviceIds) as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getAll(): Promise<EncryptedCookieBlob[]> {
|
||||
return (this.db.prepare("SELECT * FROM cookies").all() as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<EncryptedCookieBlob | null> {
|
||||
const row = this.db.prepare("SELECT * FROM cookies WHERE id = ?").get(id) as Record<string, unknown> | undefined;
|
||||
return row ? toBlob(row) : null;
|
||||
}
|
||||
|
||||
async getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]> {
|
||||
if (deviceIds.length === 0) return [];
|
||||
const placeholders = deviceIds.map(() => "?").join(",");
|
||||
return (this.db.prepare(
|
||||
`SELECT * FROM cookies WHERE device_id IN (${placeholders}) AND updated_at > ?`,
|
||||
).all(...deviceIds, since) as Record<string, unknown>[]).map(toBlob);
|
||||
}
|
||||
}
|
||||
|
||||
// --- SQLite Device Store ---
|
||||
|
||||
class SqliteDeviceStore implements IDeviceStore {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
async register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo> {
|
||||
const existing = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record<string, unknown> | undefined;
|
||||
if (existing) return toDevice(existing);
|
||||
|
||||
const token = generateToken();
|
||||
const createdAt = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
"INSERT INTO devices (device_id, name, platform, enc_pub, token, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(deviceId, name, platform, encPub, token, createdAt);
|
||||
|
||||
return { deviceId, name, platform, encPub, token, createdAt };
|
||||
}
|
||||
|
||||
async getByToken(token: string): Promise<DeviceInfo | null> {
|
||||
const row = this.db.prepare("SELECT * FROM devices WHERE token = ?").get(token) as Record<string, unknown> | undefined;
|
||||
return row ? toDevice(row) : null;
|
||||
}
|
||||
|
||||
async getById(deviceId: string): Promise<DeviceInfo | null> {
|
||||
const row = this.db.prepare("SELECT * FROM devices WHERE device_id = ?").get(deviceId) as Record<string, unknown> | undefined;
|
||||
return row ? toDevice(row) : null;
|
||||
}
|
||||
|
||||
async addPairing(deviceIdA: string, deviceIdB: string): Promise<void> {
|
||||
this.db.prepare(
|
||||
"INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
|
||||
).run(deviceIdA, deviceIdB);
|
||||
this.db.prepare(
|
||||
"INSERT OR IGNORE INTO pairings (device_id_a, device_id_b) VALUES (?, ?)",
|
||||
).run(deviceIdB, deviceIdA);
|
||||
}
|
||||
|
||||
async getPairedDevices(deviceId: string): Promise<string[]> {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT device_id_b FROM pairings WHERE device_id_a = ?",
|
||||
).all(deviceId) as { device_id_b: string }[];
|
||||
return rows.map((r) => r.device_id_b);
|
||||
}
|
||||
|
||||
async getPairingGroup(deviceId: string): Promise<string[]> {
|
||||
const paired = await this.getPairedDevices(deviceId);
|
||||
return [deviceId, ...paired];
|
||||
}
|
||||
|
||||
async listAll(): Promise<DeviceInfo[]> {
|
||||
return (this.db.prepare("SELECT * FROM devices").all() as Record<string, unknown>[]).map(toDevice);
|
||||
}
|
||||
|
||||
async revoke(deviceId: string): Promise<boolean> {
|
||||
const result = this.db.prepare("DELETE FROM devices WHERE device_id = ?").run(deviceId);
|
||||
if (result.changes === 0) return false;
|
||||
this.db.prepare("DELETE FROM pairings WHERE device_id_a = ? OR device_id_b = ?").run(deviceId, deviceId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- SQLite Agent Store ---
|
||||
|
||||
class SqliteAgentStore implements IAgentStore {
|
||||
constructor(private db: Database.Database) {}
|
||||
|
||||
async create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken> {
|
||||
const id = generateId();
|
||||
const token = generateToken();
|
||||
const createdAt = new Date().toISOString();
|
||||
this.db.prepare(
|
||||
"INSERT INTO agents (id, name, token, enc_pub, allowed_domains, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
).run(id, name, token, encPub, JSON.stringify(allowedDomains), createdAt);
|
||||
return { id, name, token, encPub, allowedDomains, createdAt };
|
||||
}
|
||||
|
||||
async getByToken(token: string): Promise<AgentToken | null> {
|
||||
const row = this.db.prepare("SELECT * FROM agents WHERE token = ?").get(token) as Record<string, unknown> | undefined;
|
||||
return row ? toAgent(row) : null;
|
||||
}
|
||||
|
||||
async grantAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
this.db.prepare(
|
||||
"INSERT OR IGNORE INTO agent_device_access (agent_id, device_id) VALUES (?, ?)",
|
||||
).run(agentId, deviceId);
|
||||
}
|
||||
|
||||
async getAccessibleDevices(agentId: string): Promise<string[]> {
|
||||
const rows = this.db.prepare(
|
||||
"SELECT device_id FROM agent_device_access WHERE agent_id = ?",
|
||||
).all(agentId) as { device_id: string }[];
|
||||
return rows.map((r) => r.device_id);
|
||||
}
|
||||
|
||||
async revokeAccess(agentId: string, deviceId: string): Promise<void> {
|
||||
this.db.prepare(
|
||||
"DELETE FROM agent_device_access WHERE agent_id = ? AND device_id = ?",
|
||||
).run(agentId, deviceId);
|
||||
}
|
||||
}
|
||||
|
||||
// --- SQLite Admin Store ---
|
||||
|
||||
class SqliteAdminStore implements IAdminStore {
|
||||
private jwtSecret: string;
|
||||
private _settings: AdminSettings;
|
||||
|
||||
constructor(private db: Database.Database) {
|
||||
// Load or generate JWT secret
|
||||
const meta = this.db.prepare("SELECT value FROM admin_meta WHERE key = 'jwt_secret'").get() as { value: string } | undefined;
|
||||
if (meta) {
|
||||
this.jwtSecret = meta.value;
|
||||
} else {
|
||||
this.jwtSecret = crypto.randomBytes(32).toString("hex");
|
||||
this.db.prepare("INSERT INTO admin_meta (key, value) VALUES ('jwt_secret', ?)").run(this.jwtSecret);
|
||||
}
|
||||
|
||||
// Load settings
|
||||
this._settings = { ...DEFAULT_SETTINGS };
|
||||
const settingsRow = this.db.prepare("SELECT value FROM admin_settings WHERE key = 'settings'").get() as { value: string } | undefined;
|
||||
if (settingsRow) {
|
||||
Object.assign(this._settings, JSON.parse(settingsRow.value));
|
||||
}
|
||||
}
|
||||
|
||||
get isSetUp(): boolean {
|
||||
const row = this.db.prepare("SELECT COUNT(*) as cnt FROM admin_users").get() as { cnt: number };
|
||||
return row.cnt > 0;
|
||||
}
|
||||
|
||||
async setup(username: string, password: string): Promise<void> {
|
||||
if (this.isSetUp) throw new Error("Already configured");
|
||||
const salt = crypto.randomBytes(16).toString("hex");
|
||||
const hash = await hashPassword(password, salt);
|
||||
this.db.prepare(
|
||||
"INSERT INTO admin_users (username, password_hash, salt, created_at) VALUES (?, ?, ?, ?)",
|
||||
).run(username, hash, salt, new Date().toISOString());
|
||||
}
|
||||
|
||||
async login(username: string, password: string): Promise<string> {
|
||||
const user = this.db.prepare("SELECT * FROM admin_users WHERE username = ?").get(username) as Record<string, unknown> | undefined;
|
||||
if (!user) throw new Error("Invalid credentials");
|
||||
const hash = await hashPassword(password, user.salt as string);
|
||||
if (hash !== user.password_hash) throw new Error("Invalid credentials");
|
||||
return jwt.sign({ sub: username, role: "admin" }, this.jwtSecret, { expiresIn: "24h" });
|
||||
}
|
||||
|
||||
verifyToken(token: string): { sub: string; role: string } {
|
||||
return jwt.verify(token, this.jwtSecret) as { sub: string; role: string };
|
||||
}
|
||||
|
||||
getUser(): { username: string; createdAt: string } | null {
|
||||
const row = this.db.prepare("SELECT username, created_at FROM admin_users LIMIT 1").get() as Record<string, unknown> | undefined;
|
||||
if (!row) return null;
|
||||
return { username: row.username as string, createdAt: row.created_at as string };
|
||||
}
|
||||
|
||||
getSettings(): AdminSettings {
|
||||
return { ...this._settings };
|
||||
}
|
||||
|
||||
updateSettings(patch: Partial<AdminSettings>): AdminSettings {
|
||||
Object.assign(this._settings, patch);
|
||||
this.db.prepare(
|
||||
"INSERT INTO admin_settings (key, value) VALUES ('settings', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
).run(JSON.stringify(this._settings));
|
||||
return { ...this._settings };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Factory ---
|
||||
|
||||
export function createSqliteStores(config: SqliteConfig): DataStores {
|
||||
const db = new Database(config.path);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.pragma("foreign_keys = ON");
|
||||
initSchema(db);
|
||||
|
||||
return {
|
||||
cookieStore: new SqliteCookieStore(db),
|
||||
deviceStore: new SqliteDeviceStore(db),
|
||||
agentStore: new SqliteAgentStore(db),
|
||||
adminStore: new SqliteAdminStore(db),
|
||||
async close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
72
src/relay/db/types.ts
Normal file
72
src/relay/db/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { EncryptedCookieBlob, DeviceInfo, AgentToken } from "../../protocol/spec.js";
|
||||
import type { AdminSettings, AdminUser } from "../admin/auth.js";
|
||||
|
||||
// --- Database configuration ---
|
||||
|
||||
export type DbType = "sqlite" | "mysql";
|
||||
|
||||
export interface SqliteConfig {
|
||||
type: "sqlite";
|
||||
path: string; // file path, e.g. "./data/cookiebridge.db"
|
||||
}
|
||||
|
||||
export interface MysqlConfig {
|
||||
type: "mysql";
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
}
|
||||
|
||||
export type DbConfig = SqliteConfig | MysqlConfig;
|
||||
|
||||
// --- Store interfaces ---
|
||||
|
||||
export interface ICookieStore {
|
||||
upsert(blob: Omit<EncryptedCookieBlob, "id" | "updatedAt">): Promise<EncryptedCookieBlob>;
|
||||
delete(deviceId: string, domain: string, cookieName: string, path: string): Promise<boolean>;
|
||||
deleteById(id: string): Promise<boolean>;
|
||||
getByDevice(deviceId: string, domain?: string): Promise<EncryptedCookieBlob[]>;
|
||||
getByDevices(deviceIds: string[], domain?: string): Promise<EncryptedCookieBlob[]>;
|
||||
getAll(): Promise<EncryptedCookieBlob[]>;
|
||||
getById(id: string): Promise<EncryptedCookieBlob | null>;
|
||||
getUpdatedSince(deviceIds: string[], since: string): Promise<EncryptedCookieBlob[]>;
|
||||
}
|
||||
|
||||
export interface IDeviceStore {
|
||||
register(deviceId: string, name: string, platform: string, encPub: string): Promise<DeviceInfo>;
|
||||
getByToken(token: string): Promise<DeviceInfo | null>;
|
||||
getById(deviceId: string): Promise<DeviceInfo | null>;
|
||||
addPairing(deviceIdA: string, deviceIdB: string): Promise<void>;
|
||||
getPairedDevices(deviceId: string): Promise<string[]>;
|
||||
getPairingGroup(deviceId: string): Promise<string[]>;
|
||||
listAll(): Promise<DeviceInfo[]>;
|
||||
revoke(deviceId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface IAgentStore {
|
||||
create(name: string, encPub: string, allowedDomains: string[]): Promise<AgentToken>;
|
||||
getByToken(token: string): Promise<AgentToken | null>;
|
||||
grantAccess(agentId: string, deviceId: string): Promise<void>;
|
||||
getAccessibleDevices(agentId: string): Promise<string[]>;
|
||||
revokeAccess(agentId: string, deviceId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IAdminStore {
|
||||
readonly isSetUp: boolean;
|
||||
setup(username: string, password: string): Promise<void>;
|
||||
login(username: string, password: string): Promise<string>;
|
||||
verifyToken(token: string): { sub: string; role: string };
|
||||
getUser(): { username: string; createdAt: string } | null;
|
||||
getSettings(): AdminSettings;
|
||||
updateSettings(patch: Partial<AdminSettings>): AdminSettings;
|
||||
}
|
||||
|
||||
export interface DataStores {
|
||||
cookieStore: ICookieStore;
|
||||
deviceStore: IDeviceStore;
|
||||
agentStore: IAgentStore;
|
||||
adminStore: IAdminStore;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user