/** * Hyperdrive Management System: registry on personal drive, extra drives + swarm, * Autopass for invite/pair. Use a static ESM import (not createRequire) so Pear can * trace and stage `autopass`; bare-module require() from pear:// URLs does not resolve node_modules. */ import Autopass from 'autopass' import b4a from 'b4a' import { randomBytes } from 'bare-crypto' import Hyperbee from 'hyperbee' import hcCrypto from 'hypercore-crypto' import idEnc from 'hypercore-id-encoding' import { parseHdmsPairArgv } from './hdms-cli-argv.js' import { bareOsHostBooterWarn } from './bare-os-host-booter-log.js' import { HDMS_AUTOPASS_SHARE_KEY, HDMS_AUTOPASS_SHARE_RW_KEY, decodeRwShareOffer, decodeShareOffer, removeBothHdmsShareKeys, writerSecretBytesFromHex } from './hdms-share-offer.js' export const HDMS_REGISTRY_PATH = '/.bare/hdms/registry.json' /** * After BlindPairing, the HyperDB view may lag behind membership; autopass's own tests * wait for `base.system.members === 2` before reading records. * @param {{ base: { system?: { members?: number }, on: Function, off: Function, update?: () => Promise } }} pass * @param {number} timeoutMs */ async function waitForAutopassMembersReady(pass, timeoutMs) { const deadline = Date.now() + timeoutMs return new Promise((resolve) => { let iv = null const cleanup = () => { if (iv) clearInterval(iv) iv = null try { pass.base?.off?.('update', onUpdate) } catch (_) {} } const tryOk = () => { try { const n = pass.base?.system?.members if (typeof n === 'number' && n >= 2) { cleanup() resolve() return true } } catch (_) {} if (Date.now() >= deadline) { cleanup() resolve() return true } return false } const onUpdate = () => { void pass.base?.update?.().catch(() => {}) tryOk() } try { pass.base?.on?.('update', onUpdate) } catch (_) {} iv = setInterval(() => { void pass.base?.update?.().catch(() => {}) tryOk() }, 100) void pass.base?.update?.().catch(() => {}) tryOk() }) } /** * Prefer RW offer (separate Autopass key) so legacy clients never treat RW as RO-only. * @param {{ get: (k: string) => Promise<{ value?: unknown } | null>, base?: { update?: () => Promise } }} pass * @param {number} timeoutMs * @returns {Promise<{ kind: 'rw', label: string, key: string, signerKey: string, writerSecretHex: string } | { kind: 'ro', label: string, key: string } | null>} */ async function waitForAutopassShare(pass, timeoutMs) { const deadline = Date.now() + timeoutMs while (Date.now() < deadline) { try { await pass.base?.update?.() } catch (_) {} const rw = decodeRwShareOffer(await pass.get(HDMS_AUTOPASS_SHARE_RW_KEY)) if (rw) return { kind: 'rw', ...rw } const ro = decodeShareOffer(await pass.get(HDMS_AUTOPASS_SHARE_KEY)) if (ro) return { kind: 'ro', ...ro } await new Promise((r) => setTimeout(r, 300)) } return null } /** Max ms to wait for Autobase membership after pair before polling share records. */ function parseHdmsPairReadyMs() { const raw = globalThis.process?.env?.BARE_OS_HDMS_PAIR_READY_MS let ms = 45_000 if (raw != null && String(raw).trim() !== '') { const n = Number(raw) if (Number.isFinite(n) && n >= 0) ms = Math.min(120_000, n) } return ms } /** Max ms to wait for `@autopass/invite` to disappear from the view after deleteInvite. */ function parseHdmsInviteClearMs() { const raw = globalThis.process?.env?.BARE_OS_HDMS_INVITE_CLEAR_MS let ms = 30_000 if (raw != null && String(raw).trim() !== '') { const n = Number(raw) if (Number.isFinite(n) && n > 0) ms = Math.min(120_000, n) } return ms } /** * Always mint a brand-new BlindPairing z32: clear any open invite row from the view first. * Autopass `createInvite()` returns the existing token while a row remains; after * `deleteInvite()` the HyperDB view can lag until `base.update()` applies the delete. * * @param {import('autopass')} ap * @param {boolean} readOnly * @returns {Promise} */ async function mintFreshHdmsAutopassInvite(ap, readOnly) { await ap.deleteInvite() if (ap.member) await ap.member.flushed() try { await ap.base.update() } catch (_) {} const deadline = Date.now() + parseHdmsInviteClearMs() while (Date.now() < deadline) { const existing = await ap.base.view.findOne('@autopass/invite', {}) if (existing === null) break await ap.deleteInvite() if (ap.member) await ap.member.flushed() try { await ap.base.update() } catch (_) {} await new Promise((r) => setTimeout(r, 50)) } const left = await ap.base.view.findOne('@autopass/invite', {}) if (left !== null) { throw new Error( 'HDMS: prior Autopass invite did not clear from view (try hdms invite again or check swarm/replication)' ) } return await ap.createInvite({ readOnly }) } /** @param {string} label */ export function assertValidHdmsLabel(label) { if (!label || typeof label !== 'string') throw new Error('Invalid label') if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,62}$/.test(label)) { throw new Error( 'Label must start with alphanumeric; allowed: . _ - (max 63 chars)' ) } } function randomNsSuffix() { return b4a.toString(randomBytes(8), 'hex') } /** Max time to wait for BlindPairing to complete (inviter must be reachable). */ function parseHdmsPairWaitMs() { const raw = globalThis.process?.env?.BARE_OS_HDMS_PAIR_WAIT_MS let ms = 120_000 if (raw != null && String(raw).trim() !== '') { const n = Number(raw) if (Number.isFinite(n) && n > 0) ms = Math.min(600_000, n) } return ms } /** * Prefer inviter's label; if already mounted locally, try label-2 … label-99 (63-char cap). * @param {Map} byLabel * @param {string} base */ function pickUniqueHdmsMountLabel(byLabel, base) { assertValidHdmsLabel(base) if (!byLabel.has(base)) return base for (let n = 2; n <= 99; n++) { const suffix = '-' + n if (base.length + suffix.length > 63) break const candidate = base + suffix assertValidHdmsLabel(candidate) if (!byLabel.has(candidate)) return candidate } throw new Error( 'HDMS mount label in use: ' + base + ' (remove it or ask inviter to use another name)' ) } /** * Writable replica: `key` (drive z32) + `signerKey` (writer pk z32) + `writerSecretHex`, no `ns`. * @typedef {{ id: string, label: string, mode: 'writable' | 'readonly', key?: string, ns?: string, signerKey?: string, writerSecretHex?: string }} HdmsRegistryEntry * @typedef {{ version: 1, drives: HdmsRegistryEntry[] }} HdmsRegistryFile */ /** * @param {import('hyperdrive').default} personalDrive * @returns {Promise} */ export async function loadHdmsRegistry(personalDrive) { const buf = await personalDrive.get(HDMS_REGISTRY_PATH, { follow: true }) if (!buf || buf.length === 0) { return { version: 1, drives: [] } } try { const j = JSON.parse(b4a.toString(buf)) if (j && j.version === 1 && Array.isArray(j.drives)) return j } catch { /* fallthrough */ } return { version: 1, drives: [] } } /** * @param {import('hyperdrive').default} personalDrive * @param {HdmsRegistryFile} data */ export async function saveHdmsRegistry(personalDrive, data) { const json = JSON.stringify(data, null, 0) await personalDrive.put(HDMS_REGISTRY_PATH, b4a.from(json)) } export class HdmsController { constructor() { /** @type {import('corestore').default | null} */ this.store = null /** @type {import('hyperswarm').default | null} */ this.swarm = null /** @type {typeof import('hyperdrive').default | null} */ this.Hyperdrive = null /** @type {import('hyperdrive').default | null} */ this.personalDrive = null /** @type {import('./swarm-disk.js').SwarmDisk | null} */ this.disk = null /** @type {{ getMounts: () => Map } | null} */ this.vfsMountRef = null /** @type {string[] | null} */ this.bootstrap = null /** @type {HdmsRegistryFile | null} */ this.registry = null /** @type {Map} */ this.byLabel = new Map() /** @type {import('autopass') | null} */ this.autopass = null this.active = false } /** * @param {{ * store: import('corestore').default, * swarm: import('hyperswarm').default, * Hyperdrive: typeof import('hyperdrive').default, * personalDrive: import('hyperdrive').default, * disk: import('./swarm-disk.js').SwarmDisk, * vfsMountRef: { getMounts: () => Map }, * bootstrap?: string[] | null, * onAfterActivate?: (info: { labels: string[] }) => void | Promise * }} opts */ async activate(opts) { if (this.active) await this.deactivate() this.store = opts.store this.swarm = opts.swarm this.Hyperdrive = opts.Hyperdrive this.personalDrive = opts.personalDrive this.disk = opts.disk this.vfsMountRef = opts.vfsMountRef const b = opts.bootstrap this.bootstrap = Array.isArray(b) && b.length ? b : (parseBootstrapEnv() ?? null) this.registry = await loadHdmsRegistry(this.personalDrive) this.byLabel.clear() this.disk.auxiliaryDrives = [] for (const entry of this.registry.drives) { try { await this._openEntry(entry) } catch (err) { bareOsHostBooterWarn( 'hdms_skip_drive', 'skip drive ' + entry.label, err?.message || String(err) ) } } this.vfsMountRef.getMounts = () => this.getMountMap() this.active = true if (typeof opts.onAfterActivate === 'function') { try { await opts.onAfterActivate({ labels: [...this.byLabel.keys()] }) } catch (e) { bareOsHostBooterWarn( 'hdms_on_after_activate', 'onAfterActivate failed', e?.message || String(e) ) } } } async deactivate() { if (this.autopass) { try { await this.autopass.close() } catch (_) {} this.autopass = null } for (const { drive, entry } of this.byLabel.values()) { try { if (this.swarm && drive.discoveryKey) { this.swarm.leave(drive.discoveryKey) } } catch (_) {} try { await drive.close() } catch (_) {} } this.byLabel.clear() if (this.disk) this.disk.auxiliaryDrives = [] if (this.vfsMountRef) { this.vfsMountRef.getMounts = () => new Map() } this.registry = null this.active = false } /** * @param {HdmsRegistryEntry} entry */ async _openEntry(entry) { const Hyperdrive = this.Hyperdrive const store = this.store const swarm = this.swarm if (!Hyperdrive || !store || !swarm) throw new Error('HDMS not configured') let drive if (entry.mode === 'writable' && entry.ns) { const ns = store.namespace(entry.ns, { writable: true }) drive = new Hyperdrive(ns) } else if ( entry.mode === 'writable' && entry.key && entry.signerKey && entry.writerSecretHex && !entry.ns ) { const driveKey = idEnc.decode(entry.key) const signerPk = idEnc.decode(entry.signerKey) const secretKey = writerSecretBytesFromHex(entry.writerSecretHex) if (!hcCrypto.validateKeyPair({ publicKey: signerPk, secretKey })) { throw new Error('writer secret does not match signer key (re-pair with a fresh invite)') } const core = store.get({ key: driveKey, keyPair: { publicKey: signerPk, secretKey }, exclusive: true }) const bee = new Hyperbee(core, { keyEncoding: 'utf-8', valueEncoding: 'json', metadata: { contentFeed: null } }) drive = new Hyperdrive(store, null, { _db: bee }) } else if (entry.mode === 'readonly' && entry.key) { const key = idEnc.decode(entry.key) drive = new Hyperdrive(store, key) } else { throw new Error('Bad registry entry') } await drive.ready() swarm.join(drive.discoveryKey) const done = drive.findingPeers() // Hyperdrive blocks ops until findingPeers `done()` runs; `swarm.flush()` can // hang when DHT/announce never settles — always finish after a bounded wait. const flushMsRaw = globalThis.process?.env?.BARE_OS_HDMS_SWARM_FLUSH_MS let flushMs = 8000 if (flushMsRaw != null && String(flushMsRaw).trim() !== '') { const n = Number(flushMsRaw) if (Number.isFinite(n) && n >= 0) flushMs = Math.min(120_000, n) } const finishFinding = () => { try { done() } catch (_) {} } const t = flushMs > 0 ? setTimeout(finishFinding, flushMs) : null swarm.flush().then( () => { if (t) clearTimeout(t) finishFinding() }, () => { if (t) clearTimeout(t) finishFinding() } ) if (this.disk) { this.disk.auxiliaryDrives.push(drive) } this.byLabel.set(entry.label, { drive, entry, writable: entry.mode === 'writable' }) for (const peer of this.disk?.peers || []) { try { drive.replicate(peer.mux.stream, { live: true, download: true }) } catch (_) {} } } getMountMap() { /** @type {Map} */ const m = new Map() for (const [label, x] of this.byLabel) { m.set(label, { drive: x.drive, writable: x.writable }) } return m } /** * @param {Record} ctx */ assertLoggedIn(ctx) { if (ctx.identity?.state !== 'unlocked') { throw new Error('Log in to use Hyperdrive management (hdms)') } if (!this.active) throw new Error('HDMS inactive') } /** * @param {Record} ctx */ async list(ctx) { this.assertLoggedIn(ctx) const reg = this.registry const inReg = new Set( reg && Array.isArray(reg.drives) ? reg.drives.map((d) => d.label) : [] ) const lines = [] if (reg && reg.drives.length) { for (const d of reg.drives) { const k = d.key || '(local)' lines.push(`${d.label}\t${d.mode}\t${k}`) } } for (const [label, x] of this.byLabel) { if (inReg.has(label)) continue const d = x.entry const k = d.key || '(local)' lines.push(`${d.label}\t${d.mode}\t${k}\tephemeral`) } if (!lines.length) { ctx.console.log('(no extra drives)') return } for (const line of lines) ctx.console.log(line) } /** * @param {Record} ctx * @param {string} label */ async create(ctx, label) { this.assertLoggedIn(ctx) assertValidHdmsLabel(label) if (this.byLabel.has(label)) throw new Error('Label already exists') const id = randomNsSuffix() const ns = `bare-os-hdms-w-${id}` /** @type {HdmsRegistryEntry} */ const entry = { id, label, mode: 'writable', ns, key: '' } // Single Hyperdrive open: corestore uses exclusive db cores per namespace; a // second `new Hyperdrive(ns)` while the first is still open deadlocks on ready(). await this._openEntry(entry) const opened = this.byLabel.get(label) if (!opened?.drive?.key) { throw new Error('HDMS create failed (no drive key)') } entry.key = idEnc.encode(opened.drive.key) this.registry.drives.push(entry) await saveHdmsRegistry(this.personalDrive, this.registry) ctx.console.log(`Created ${label} key=${entry.key}`) } /** * @param {Record} ctx * @param {string} label * @param {string} keyZ32 * @param {{ persist?: boolean }} [opts] */ async addReadonly(ctx, label, keyZ32, opts = {}) { const persist = opts.persist !== false this.assertLoggedIn(ctx) assertValidHdmsLabel(label) if (this.byLabel.has(label)) throw new Error('Label already exists') idEnc.decode(keyZ32) const id = randomNsSuffix() /** @type {HdmsRegistryEntry} */ const entry = { id, label, mode: 'readonly', key: keyZ32 } if (persist) { this.registry.drives.push(entry) await saveHdmsRegistry(this.personalDrive, this.registry) } await this._openEntry(entry) if (!persist) { const slot = this.byLabel.get(label) if (slot) slot.ephemeral = true } ctx.console.log(`Added readonly ${label}${persist ? '' : ' (ephemeral)'}`) } /** * Writable Hyperdrive replica: `keyZ32` is drive id (manifest), `signerKeyZ32` is metadata signer pk. * @param {Record} ctx * @param {string} label * @param {string} keyZ32 drive public key (z32) * @param {string} signerKeyZ32 metadata writer public key (z32) * @param {string} writerSecretHex * @param {{ persist?: boolean }} [opts] */ async addWritableReplica( ctx, label, keyZ32, signerKeyZ32, writerSecretHex, opts = {} ) { const persist = opts.persist !== false this.assertLoggedIn(ctx) assertValidHdmsLabel(label) if (this.byLabel.has(label)) throw new Error('Label already exists') idEnc.decode(keyZ32) const signerPk = idEnc.decode(signerKeyZ32) const secretKey = writerSecretBytesFromHex(writerSecretHex) if (!hcCrypto.validateKeyPair({ publicKey: signerPk, secretKey })) { throw new Error('writer secret does not match signer key') } const id = randomNsSuffix() /** @type {HdmsRegistryEntry} */ const entry = { id, label, mode: 'writable', key: keyZ32, signerKey: signerKeyZ32, writerSecretHex: b4a.toString(secretKey, 'hex') } if (persist) { this.registry.drives.push(entry) await saveHdmsRegistry(this.personalDrive, this.registry) } await this._openEntry(entry) if (!persist) { const slot = this.byLabel.get(label) if (slot) slot.ephemeral = true } ctx.console.log(`Added writable ${label}${persist ? '' : ' (ephemeral)'}`) } /** * @param {Record} ctx * @param {string} label */ async remove(ctx, label) { this.assertLoggedIn(ctx) const open = this.byLabel.get(label) if (open?.ephemeral) { try { if (this.swarm && open.drive.discoveryKey) { this.swarm.leave(open.drive.discoveryKey) } } catch (_) {} try { await open.drive.close() } catch (_) {} if (this.disk?.auxiliaryDrives) { this.disk.auxiliaryDrives = this.disk.auxiliaryDrives.filter( (d) => d !== open.drive ) } this.byLabel.delete(label) ctx.console.log('Removed ' + label) return } const idx = this.registry.drives.findIndex((d) => d.label === label) if (idx < 0) throw new Error('Unknown label: ' + label) const openReg = this.byLabel.get(label) if (openReg) { try { if (this.swarm && openReg.drive.discoveryKey) { this.swarm.leave(openReg.drive.discoveryKey) } } catch (_) {} try { await openReg.drive.close() } catch (_) {} if (this.disk?.auxiliaryDrives) { this.disk.auxiliaryDrives = this.disk.auxiliaryDrives.filter( (d) => d !== openReg.drive ) } this.byLabel.delete(label) } this.registry.drives.splice(idx, 1) await saveHdmsRegistry(this.personalDrive, this.registry) ctx.console.log('Removed ' + label) } /** * @param {Record} ctx * @param {string} label */ async show(ctx, label) { this.assertLoggedIn(ctx) let e = this.registry.drives.find((d) => d.label === label) if (!e) { const open = this.byLabel.get(label) if (open?.ephemeral) { e = { ...open.entry, ephemeral: true } } } if (!e) throw new Error('Unknown label: ' + label) ctx.console.log(JSON.stringify(e, null, 2)) } /** * @param {Record} ctx * @param {boolean} readOnly Autopass writer role for the peer (not Hyperdrive R/W). * @param {string} [driveLabel] Writable HDMS label to attach to the invite. * @param {boolean} [shareReadWrite] When true with `driveLabel`, publish writer secret on `bare-os-hdms/pending-share-rw`. */ async invite(ctx, readOnly, driveLabel, shareReadWrite = false) { this.assertLoggedIn(ctx) await this._ensureAutopass() const ap = this.autopass if (!ap) throw new Error('HDMS autopass unavailable') await removeBothHdmsShareKeys(ap) if (driveLabel != null && String(driveLabel).trim() !== '') { assertValidHdmsLabel(driveLabel) const open = this.byLabel.get(driveLabel) if (!open) throw new Error('Unknown label: ' + driveLabel) if (open.entry.mode !== 'writable') { throw new Error('hdms invite