Files
dsh-remote/lib/store.mjs
T

192 lines
5.9 KiB
JavaScript

// Persistent state for dsh-remote: device identity, paired devices (master) or master
// link credentials (slave), sync bookkeeping. One JSON file, written atomically, 0600.
import crypto from 'node:crypto'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
let PROFILE_DIR = ''
/** Set once from ctx.profileContext.dir at plugin start (authoritative). */
export function setProfileDir(dir) { if (dir) PROFILE_DIR = dir }
export function profileDir() {
if (PROFILE_DIR) return PROFILE_DIR
if (process.env.DSH_PROFILE_DIR) return process.env.DSH_PROFILE_DIR
const home = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
return path.join(home, 'profiles', process.env.DSH_PROFILE || 'web')
}
export function stateDir() {
return path.join(profileDir(), 'dsh-remote')
}
const b64u = (buf) => Buffer.from(buf).toString('base64url')
export const randomId = (bytes = 16) => b64u(crypto.randomBytes(bytes))
export const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex')
export function safeEqual(a, b) {
const x = Buffer.from(String(a))
const y = Buffer.from(String(b))
return x.length === y.length && crypto.timingSafeEqual(x, y)
}
/** Device id: lowercase slug, safe in URL paths (/r/<id>/). */
export function slug(s) {
const v = String(s || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40)
return v || 'device'
}
export class Store {
constructor(file = path.join(stateDir(), 'state.json')) {
this.file = file
this.data = this._load()
}
_load() {
try {
const v = JSON.parse(fs.readFileSync(this.file, 'utf8'))
if (v && typeof v === 'object') return v
} catch { /* first run */ }
return {}
}
save() {
fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 })
const tmp = `${this.file}.${process.pid}.${Date.now()}.tmp`
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2), { mode: 0o600 })
fs.renameSync(tmp, this.file)
}
/** This installation's stable identity. */
identity(defaultName) {
if (!this.data.self) {
const name = defaultName || os.hostname()
this.data.self = { id: slug(name), name, createdAt: Date.now() }
this.save()
}
return this.data.self
}
// ---- master side: paired devices + pairing codes -------------------------
devices() { return (this.data.devices ||= {}) }
pairingCodes() { return (this.data.pairing ||= {}) }
createPairingCode({ ttlMs = 15 * 60 * 1000, label = '' } = {}) {
// 4 groups of 4 from an unambiguous alphabet: easy to type, ~80 bits.
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const raw = crypto.randomBytes(16)
const code = [...raw].map((b) => alphabet[b % alphabet.length]).join('').replace(/(.{4})(?=.)/g, '$1-')
this.pairingCodes()[sha256(code)] = { expiresAt: Date.now() + ttlMs, label, createdAt: Date.now() }
this._prunePairing()
this.save()
return { code, expiresAt: Date.now() + ttlMs }
}
consumePairingCode(code) {
this._prunePairing()
const key = sha256(String(code || '').trim().toUpperCase())
const rec = this.pairingCodes()[key]
if (!rec) return null
delete this.pairingCodes()[key]
this.save()
return rec
}
_prunePairing() {
const now = Date.now()
for (const [k, v] of Object.entries(this.pairingCodes())) if (!v || v.expiresAt < now) delete this.pairingCodes()[k]
}
/** Register (or re-register) a device; returns its secret key (shown once). */
addDevice({ id, name, platform }) {
const devices = this.devices()
let devId = slug(id || name)
// Keep ids unique; a re-pair of the same machine replaces its key.
const secret = randomId(32)
devices[devId] = {
id: devId,
name: name || devId,
platform: platform || '',
keyHash: sha256(secret),
createdAt: devices[devId]?.createdAt || Date.now(),
revoked: false,
sync: devices[devId]?.sync || { enabled: true, allow: [], deny: [], secrets: [] },
}
this.save()
return { id: devId, secret }
}
verifyDevice(id, secret) {
const d = this.devices()[id]
if (!d || d.revoked) return null
return safeEqual(d.keyHash, sha256(String(secret || ''))) ? d : null
}
revokeDevice(id) {
const d = this.devices()[id]
if (!d) return false
d.revoked = true
d.revokedAt = Date.now()
this.save()
return true
}
removeDevice(id) {
if (!this.devices()[id]) return false
delete this.devices()[id]
this.save()
return true
}
// ---- UI-managed node settings ---------------------------------------------
/** { nodeType?: 'master'|'slave', authMiddleware?: boolean } — set from Settings → Remote devices. */
settings() { return (this.data.settings ||= {}) }
setSettings(patch) {
this.data.settings = { ...this.settings(), ...patch }
this.save()
return this.data.settings
}
// ---- slave side: link to master ------------------------------------------
get master() { return this.data.master || null }
setMaster(m) {
this.data.master = m
this.save()
}
// ---- session version control & offline notification queue ----------------
sessionLineage() { return (this.data.sessionLineage ||= {}) }
recordLineage(sessionId, info) {
const table = this.sessionLineage()
table[sessionId] = { ...(table[sessionId] || {}), ...info, updatedAt: Date.now() }
this.save()
return table[sessionId]
}
pendingMoves() { return (this.data.pendingMoves ||= {}) }
queueMoveNotice(notice) {
const key = `${notice.fromDevice}:${notice.sessionId}`
this.pendingMoves()[key] = { ...notice, queuedAt: Date.now() }
this.save()
}
popPendingMovesForDevice(deviceId) {
const moves = this.pendingMoves()
const matching = []
for (const [k, v] of Object.entries(moves)) {
if (v.fromDevice === deviceId) {
matching.push(v)
delete moves[k]
}
}
if (matching.length > 0) this.save()
return matching
}
}