const crypto = require("crypto"); const DEFAULT_CAPABILITIES = Object.freeze(["transcription.capture.v1", "transcription.settings.v1", "obs.caption.native.v1"]); class DeviceStore { constructor(db, options = {}) { this.db = db; this.now = options.now || Date.now; this.randomBytes = options.randomBytes || crypto.randomBytes; this.migrate(); this.cleanup(); } migrate() { this.db.exec(` CREATE TABLE IF NOT EXISTS transcription_pairing_tokens ( id TEXT PRIMARY KEY, token_hash TEXT NOT NULL UNIQUE, lumi_user_id TEXT NOT NULL, host TEXT NOT NULL, expires_at INTEGER NOT NULL, activated_at INTEGER, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS transcription_devices ( id TEXT PRIMARY KEY, install_id TEXT, name TEXT NOT NULL, lumi_user_id TEXT NOT NULL, credential_hash TEXT NOT NULL, capabilities_json TEXT NOT NULL, metadata_json TEXT NOT NULL, first_connected_at INTEGER NOT NULL, last_connected_at INTEGER NOT NULL, revoked_at INTEGER, pairing_host TEXT ); CREATE INDEX IF NOT EXISTS transcription_devices_user_idx ON transcription_devices(lumi_user_id); `); ensureColumn(this.db, "transcription_devices", "pairing_host", "TEXT"); } issuePairing({ userId, host, ttlMs = 15 * 60 * 1000 }) { if (!userId || !host) throw new Error("A Lumi user and host are required."); const id = crypto.randomUUID(); const token = tokenValue(this.randomBytes(32)); const now = this.now(); this.db.prepare("INSERT INTO transcription_pairing_tokens (id, token_hash, lumi_user_id, host, expires_at, activated_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)") .run(id, digest(token), String(userId), normalizeHost(host), now + ttlMs, now); return { pairing_id: id, token, host: normalizeHost(host), expires_at: now + ttlMs, protocol_version: 1 }; } exchange({ token, device = {} }) { const hash = digest(token); const row = this.db.prepare("SELECT * FROM transcription_pairing_tokens WHERE token_hash = ?").get(hash); if (!row || row.activated_at || row.expires_at < this.now()) { const error = new Error(row?.activated_at ? "This pairing package was already activated. Download a new companion package." : "This pairing package is invalid or expired. Download a new companion package."); error.code = row?.activated_at ? "PAIRING_ALREADY_USED" : "PAIRING_INVALID"; throw error; } const deviceId = crypto.randomUUID(); const secret = tokenValue(this.randomBytes(32)); const now = this.now(); const capabilities = DEFAULT_CAPABILITIES.slice(); const transaction = this.db.transaction(() => { const consumed = this.db.prepare("UPDATE transcription_pairing_tokens SET activated_at = ? WHERE id = ? AND activated_at IS NULL").run(now, row.id); if (consumed.changes !== 1) { const error = new Error("This pairing package was already activated. Download a new companion package."); error.code = "PAIRING_ALREADY_USED"; throw error; } this.db.prepare("INSERT INTO transcription_devices (id, install_id, name, lumi_user_id, credential_hash, capabilities_json, metadata_json, first_connected_at, last_connected_at, revoked_at, pairing_host) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)") .run(deviceId, clean(device.install_id, 128) || null, clean(device.name, 160) || "Lumi Companion", row.lumi_user_id, digest(secret), JSON.stringify(capabilities), JSON.stringify(safeMetadata(device)), now, now, row.host); }); transaction(); return { device_id: deviceId, device_secret: secret, host: row.host, capabilities, protocol_version: 1 }; } authenticate(header, requiredCapability = null) { const match = /^LumiDevice\s+([0-9a-f-]{36})\.([A-Za-z0-9_-]{40,})$/i.exec(String(header || "")); if (!match) return { allowed: false, reason: "missing_credentials" }; const row = this.db.prepare("SELECT * FROM transcription_devices WHERE id = ?").get(match[1]); if (!row || row.revoked_at || !safeEqual(row.credential_hash, digest(match[2]))) return { allowed: false, reason: row?.revoked_at ? "device_revoked" : "invalid_credentials" }; const capabilities = parseArray(row.capabilities_json); if (requiredCapability && !capabilities.includes(requiredCapability)) return { allowed: false, reason: "capability_revoked" }; this.db.prepare("UPDATE transcription_devices SET last_connected_at = ? WHERE id = ?").run(this.now(), row.id); return { allowed: true, device: serialize(row, capabilities) }; } list(options = {}) { const status = options.status === "revoked" ? "revoked" : options.status === "all" ? "all" : "active"; const where = status === "revoked" ? "WHERE revoked_at IS NOT NULL" : status === "active" ? "WHERE revoked_at IS NULL" : ""; return this.db.prepare(`SELECT * FROM transcription_devices ${where} ORDER BY last_connected_at DESC`).all().map((row) => serialize(row, parseArray(row.capabilities_json))); } revoke(deviceId) { return this.db.prepare("UPDATE transcription_devices SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL").run(this.now(), deviceId).changes === 1; } cleanup(now = this.now()) { return this.db.prepare("DELETE FROM transcription_devices WHERE revoked_at IS NOT NULL AND revoked_at <= ?").run(now - 30 * 86400000).changes; } setCapabilities(deviceId, capabilities) { const allowed = DEFAULT_CAPABILITIES.filter((capability) => new Set(capabilities || []).has(capability)); const changed = this.db.prepare("UPDATE transcription_devices SET capabilities_json = ? WHERE id = ? AND revoked_at IS NULL").run(JSON.stringify(allowed), deviceId).changes; return changed ? allowed : null; } updateRuntime(deviceId, input = {}) { const row = this.db.prepare("SELECT metadata_json FROM transcription_devices WHERE id = ? AND revoked_at IS NULL").get(deviceId); if (!row) return false; let metadata = {}; try { metadata = JSON.parse(row.metadata_json || "{}"); } catch { } if (typeof input.bridge_installed === "boolean") metadata.bridge_installed = input.bridge_installed; if (typeof input.bridge_connected === "boolean") metadata.bridge_connected = input.bridge_connected; if (input.bridge_version !== undefined) metadata.bridge_version = clean(input.bridge_version, 32) || null; if (typeof input.path_test_valid === "boolean") metadata.path_test_valid = input.path_test_valid; if (Number.isFinite(Number(input.path_test_at))) metadata.path_test_at = Math.max(0, Number(input.path_test_at)); if (input.companion_version !== undefined) metadata.companion_version = clean(input.companion_version, 32); if (input.companion_plugin_version !== undefined) metadata.companion_plugin_version = clean(input.companion_plugin_version, 32); metadata.runtime_seen_at = this.now(); return this.db.prepare("UPDATE transcription_devices SET metadata_json = ?, last_connected_at = ? WHERE id = ? AND revoked_at IS NULL") .run(JSON.stringify(metadata), this.now(), deviceId).changes === 1; } pairingAllowsHttp(token, requestOrigin) { const row = this.db.prepare("SELECT host, activated_at, expires_at FROM transcription_pairing_tokens WHERE token_hash = ?").get(digest(token)); return Boolean(row && !row.activated_at && row.expires_at >= this.now() && sameLoopbackOrigin(row.host, requestOrigin)); } } function digest(value) { return crypto.createHash("sha256").update(String(value || ""), "utf8").digest("hex"); } function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); } function tokenValue(buffer) { return Buffer.from(buffer).toString("base64url"); } function normalizeHost(value) { const url = new URL(String(value)); if (url.protocol !== "https:" && !isLoopbackHttpOrigin(url)) throw new Error("Lumi Companion requires HTTPS. HTTP is allowed only when the pairing URL explicitly uses localhost or a loopback address."); return url.origin; } function isLoopbackOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return ["http:", "https:"].includes(url.protocol) && ["localhost", "127.0.0.1", "::1", "[::1]"].includes(url.hostname); } catch { return false; } } function isLoopbackHttpOrigin(value) { try { const url = value instanceof URL ? value : new URL(String(value)); return url.protocol === "http:" && isLoopbackOrigin(url); } catch { return false; } } function sameLocalhostOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackOrigin(a) && isLoopbackOrigin(b) && a.origin === b.origin; } catch { return false; } } function sameLoopbackOrigin(left, right) { try { const a = new URL(String(left)); const b = new URL(String(right)); return isLoopbackHttpOrigin(a) && isLoopbackHttpOrigin(b) && a.origin === b.origin; } catch { return false; } } function insecureDeviceAllowed(device, requestOrigin, remoteAddress) { return ["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(String(remoteAddress || "")) && sameLoopbackOrigin(device?.pairing_host, requestOrigin); } function ensureColumn(db, table, column, type) { if (!db.prepare(`PRAGMA table_info(${table})`).all().some((entry) => entry.name === column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`); } function clean(value, max) { return String(value || "").trim().slice(0, max); } function parseArray(value) { try { const parsed = JSON.parse(value); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function safeMetadata(device) { return { companion_version: clean(device.companion_version, 32), obs_version: clean(device.obs_version, 32), os: clean(device.os, 120), architecture: clean(device.architecture, 32), hardware: clean(device.hardware, 500) }; } function serialize(row, capabilities) { return { id: row.id, install_id: row.install_id, name: row.name, lumi_user_id: row.lumi_user_id, capabilities, metadata: JSON.parse(row.metadata_json || "{}"), first_connected_at: row.first_connected_at, last_connected_at: row.last_connected_at, revoked_at: row.revoked_at, pairing_host: row.pairing_host || null }; } module.exports = { DeviceStore, DEFAULT_CAPABILITIES, digest, normalizeHost, isLoopbackOrigin, isLoopbackHttpOrigin, sameLocalhostOrigin, sameLoopbackOrigin, insecureDeviceAllowed };