Secure connections with AutoPass invites, HMAC capabilities, and viewer default.
Release rolling / release (push) Successful in 12m24s
Release rolling / release (push) Successful in 12m24s
Default peers are read-only; admin requires seed proof and operators redeem AutoPass packages with signed grants. ACL UI and docs match the new trust model.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Client-side AutoPass invite redeem → connection package.
|
||||
*
|
||||
* Package shape (JSON in peardock:pkg):
|
||||
* { v:1, publicKeyHex, capability, role, alias?, expiresAt?, jti? }
|
||||
*/
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
import Corestore from 'corestore'
|
||||
import { classifyConnectionInput } from '../shared/crypto-auth.js'
|
||||
|
||||
const PKG_KEY = 'peardock:pkg'
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
function autopassRoot() {
|
||||
const home =
|
||||
process.env.PEARDOCK_HOME ||
|
||||
process.env.HOME ||
|
||||
process.env.USERPROFILE ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
return path.join(home, '.config', 'peardock', 'autopass')
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} invite
|
||||
* @returns {string}
|
||||
*/
|
||||
function storeDirForInvite(invite) {
|
||||
const hash = crypto.createHash('sha256').update(String(invite)).digest('hex').slice(0, 16)
|
||||
return path.join(autopassRoot(), hash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Redeem an AutoPass invite and extract the PearDock connection package.
|
||||
* @param {string} inviteZ32
|
||||
* @param {{ name?: string, timeoutMs?: number }} [opts]
|
||||
* @returns {Promise<{ publicKeyHex: string, capability: string, role: string|null, alias: string|null, jti?: string, expiresAt?: string }>}
|
||||
*/
|
||||
export async function redeemAutopassInvite(inviteZ32, opts = {}) {
|
||||
const invite = String(inviteZ32 || '').trim()
|
||||
if (!invite) {
|
||||
const err = new Error('AutoPass invite required')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
if (classifyConnectionInput(invite) === 'publicKey') {
|
||||
const err = new Error('Value looks like a public key, not an AutoPass invite')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
let Autopass
|
||||
try {
|
||||
const mod = await import('autopass')
|
||||
Autopass = mod.default || mod
|
||||
} catch (err) {
|
||||
const e = new Error(`autopass not available: ${err.message}`)
|
||||
e.code = 'AUTOPASS_UNAVAILABLE'
|
||||
throw e
|
||||
}
|
||||
|
||||
const dir = storeDirForInvite(invite)
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
}
|
||||
|
||||
const store = new Corestore(path.join(dir, 'store'))
|
||||
const pair = Autopass.pair(store, invite, { name: opts.name || 'peardock-client' })
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? 60_000
|
||||
let pass
|
||||
try {
|
||||
pass = await Promise.race([
|
||||
pair.finished(),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error(`AutoPass pairing timed out after ${timeoutMs}ms`)), timeoutMs)
|
||||
}),
|
||||
])
|
||||
await pass.ready()
|
||||
|
||||
// Wait briefly for package sync if not yet present
|
||||
let record = await pass.get(PKG_KEY)
|
||||
if (!record) {
|
||||
record = await waitForPackage(pass, timeoutMs / 2)
|
||||
}
|
||||
if (!record?.value) {
|
||||
const err = new Error('AutoPass paired but peardock:pkg package not found')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
let pkg
|
||||
try {
|
||||
pkg = typeof record.value === 'string' ? JSON.parse(record.value) : record.value
|
||||
} catch {
|
||||
const err = new Error('Invalid peardock package JSON in AutoPass')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
const publicKeyHex = String(pkg.publicKeyHex || '').toLowerCase()
|
||||
const capability = String(pkg.capability || '')
|
||||
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) {
|
||||
const err = new Error('Package missing valid publicKeyHex')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
if (!capability || !capability.includes('.')) {
|
||||
const err = new Error('Package missing HMAC capability')
|
||||
err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
}
|
||||
|
||||
return {
|
||||
publicKeyHex,
|
||||
capability,
|
||||
role: pkg.role || null,
|
||||
alias: pkg.alias || null,
|
||||
jti: pkg.jti || null,
|
||||
expiresAt: pkg.expiresAt || null,
|
||||
}
|
||||
} catch (err) {
|
||||
if (!err.code) err.code = 'AUTOPASS_PAIR_FAILED'
|
||||
throw err
|
||||
} finally {
|
||||
try {
|
||||
await pair.close?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
if (pass) await pass.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
await store.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} pass
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
function waitForPackage(pass, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
const start = Date.now()
|
||||
const check = async () => {
|
||||
try {
|
||||
const rec = await pass.get(PKG_KEY)
|
||||
if (rec?.value) {
|
||||
cleanup()
|
||||
resolve(rec)
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// retry
|
||||
}
|
||||
if (Date.now() - start >= timeoutMs) {
|
||||
cleanup()
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
const onUpdate = () => {
|
||||
check()
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearInterval(timer)
|
||||
pass.off?.('update', onUpdate)
|
||||
}
|
||||
pass.on?.('update', onUpdate)
|
||||
const timer = setInterval(check, 500)
|
||||
check()
|
||||
})
|
||||
}
|
||||
|
||||
export { PKG_KEY, classifyConnectionInput }
|
||||
+46
-18
@@ -1,5 +1,10 @@
|
||||
/**
|
||||
* Single peardock server connection via HyperDHT + protomux-rpc.
|
||||
*
|
||||
* Auth modes:
|
||||
* - viewer: public key only
|
||||
* - seed: adminProof HMAC from SERVER_SEED
|
||||
* - capability: HMAC grant (AutoPass package or direct)
|
||||
*/
|
||||
import DHT from 'hyperdht'
|
||||
import ProtomuxRPC from 'protomux-rpc'
|
||||
@@ -8,6 +13,8 @@ import { EventEmitter } from 'events'
|
||||
import { PROTOCOL, Pushes, PushToType, Methods } from '../shared/protocol.js'
|
||||
import { encodings } from '../shared/encodings.js'
|
||||
import { normalizeRpcError } from './errors.js'
|
||||
import { getClientIdentity } from './identity.js'
|
||||
import { createAdminProof } from '../shared/crypto-auth.js'
|
||||
|
||||
/**
|
||||
* @param {unknown} err
|
||||
@@ -20,6 +27,9 @@ function improveRpcError(err, method) {
|
||||
/**
|
||||
* @typedef {object} ConnectionOptions
|
||||
* @property {number} [timeoutMs=30000]
|
||||
* @property {string|null} [inviteToken]
|
||||
* @property {string|null} [capability]
|
||||
* @property {string|null} [adminSeed] - SERVER_SEED hex for admin proof (session-only)
|
||||
*/
|
||||
|
||||
export class PearDockConnection extends EventEmitter {
|
||||
@@ -37,6 +47,9 @@ export class PearDockConnection extends EventEmitter {
|
||||
this.id = this.publicKeyHex.slice(0, 12)
|
||||
this.timeoutMs = opts.timeoutMs ?? 30000
|
||||
this.inviteToken = opts.inviteToken || null
|
||||
this.capability = opts.capability || null
|
||||
/** @type {string|null} session-only admin seed — never persist */
|
||||
this.adminSeed = opts.adminSeed || null
|
||||
|
||||
this.dht = null
|
||||
this.swarm = null
|
||||
@@ -50,8 +63,10 @@ export class PearDockConnection extends EventEmitter {
|
||||
/** @type {'idle'|'dialing'|'handshaking'|'ready'|'degraded'|'closed'} */
|
||||
this.state = 'idle'
|
||||
this.role = null
|
||||
this.authMode = null
|
||||
this.protocolVersion = null
|
||||
this.dockerHealth = null
|
||||
this.clientPublicKeyHex = null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,7 +77,9 @@ export class PearDockConnection extends EventEmitter {
|
||||
if (this.connected) return this
|
||||
|
||||
this.state = 'dialing'
|
||||
this.dht = new DHT()
|
||||
const identity = getClientIdentity()
|
||||
this.clientPublicKeyHex = identity.publicKeyHex
|
||||
this.dht = new DHT({ keyPair: identity.keyPair })
|
||||
this.socket = this.dht.connect(this.publicKey)
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -103,7 +120,6 @@ export class PearDockConnection extends EventEmitter {
|
||||
this.socket.off('close', onClose)
|
||||
}
|
||||
|
||||
// HyperDHT secret-stream: 'connect' after Noise handshake; some builds use 'open'
|
||||
this.socket.once('open', onOpen)
|
||||
this.socket.once('connect', onOpen)
|
||||
this.socket.once('error', onError)
|
||||
@@ -130,18 +146,40 @@ export class PearDockConnection extends EventEmitter {
|
||||
})
|
||||
this.rpc.on('close', () => this._onDisconnect())
|
||||
|
||||
// Application-layer handshake (role + protocol version)
|
||||
// Application-layer handshake (role + protocol version + auth)
|
||||
this.state = 'handshaking'
|
||||
try {
|
||||
const hs = await this.request(Methods.handshake, {
|
||||
const hsArgs = {
|
||||
clientName: 'peardock-ui',
|
||||
clientVersion: '2.0.0',
|
||||
inviteToken: this.inviteToken || undefined,
|
||||
})
|
||||
}
|
||||
if (this.capability) hsArgs.capability = this.capability
|
||||
if (this.inviteToken) hsArgs.inviteToken = this.inviteToken
|
||||
if (this.adminSeed) {
|
||||
hsArgs.adminProof = createAdminProof(this.adminSeed, {
|
||||
peerId: this.clientPublicKeyHex,
|
||||
serverPublicKeyHex: this.publicKeyHex,
|
||||
})
|
||||
}
|
||||
|
||||
const hs = await this.request(Methods.handshake, hsArgs)
|
||||
this.role = hs?.role || null
|
||||
this.authMode = hs?.auth?.mode || null
|
||||
this.protocolVersion = hs?.protocolVersion ?? null
|
||||
this.emit('handshake', hs)
|
||||
} catch (err) {
|
||||
// Fatal for auth failures — tear down so UI can show error
|
||||
if (
|
||||
err?.code === 'ADMIN_PROOF_FAILED' ||
|
||||
err?.code === 'CAPABILITY_INVALID' ||
|
||||
err?.code === 'CAPABILITY_EXPIRED' ||
|
||||
err?.code === 'CAPABILITY_SPENT' ||
|
||||
err?.code === 'PEER_DENIED' ||
|
||||
err?.code === 'INVITE_INVALID'
|
||||
) {
|
||||
await this.close().catch(() => {})
|
||||
throw improveRpcError(err, Methods.handshake)
|
||||
}
|
||||
// Older servers without handshake still usable
|
||||
this.emit('handshake-error', err)
|
||||
}
|
||||
@@ -152,20 +190,19 @@ export class PearDockConnection extends EventEmitter {
|
||||
this.state = 'ready'
|
||||
this.emit('connect')
|
||||
|
||||
// Non-blocking Docker health sample
|
||||
this.ping().catch(() => {})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Register server → client push methods.
|
||||
* Server uses rpc.event(pushName, payload); we respond and re-emit.
|
||||
*/
|
||||
_registerPushHandlers() {
|
||||
for (const push of Object.values(Pushes)) {
|
||||
this.rpc.respond(push, encodings, (payload) => {
|
||||
const type = PushToType[push] || payload?.type || push
|
||||
const message = payload && typeof payload === 'object' ? { ...payload, type } : { type, data: payload }
|
||||
const message =
|
||||
payload && typeof payload === 'object' ? { ...payload, type } : { type, data: payload }
|
||||
this.emit('message', message)
|
||||
this.emit(type, message)
|
||||
return null
|
||||
@@ -174,9 +211,6 @@ export class PearDockConnection extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* RPC request to the server.
|
||||
* protomux wraps handler throws as REQUEST_ERROR("Request failed", cause);
|
||||
* we rethrow with the unwrapped server message so UI does not show that junk.
|
||||
* @param {string} method
|
||||
* @param {object} [args]
|
||||
* @param {object} [opts]
|
||||
@@ -200,8 +234,6 @@ export class PearDockConnection extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget event (terminal input, etc.).
|
||||
* Prefer request() when a response is useful.
|
||||
* @param {string} method
|
||||
* @param {object} [args]
|
||||
*/
|
||||
@@ -211,7 +243,6 @@ export class PearDockConnection extends EventEmitter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check round-trip.
|
||||
* @returns {Promise<number>} latency ms
|
||||
*/
|
||||
async ping() {
|
||||
@@ -246,9 +277,6 @@ export class PearDockConnection extends EventEmitter {
|
||||
this.emit('disconnect')
|
||||
}
|
||||
|
||||
/**
|
||||
* Tear down connection and DHT.
|
||||
*/
|
||||
async close() {
|
||||
this.connected = false
|
||||
try {
|
||||
|
||||
+35
-8
@@ -52,9 +52,40 @@ const CODE_MAP = {
|
||||
},
|
||||
INVITE_INVALID: {
|
||||
title: 'Invite invalid',
|
||||
recovery: 'Request a new invite token from an admin.',
|
||||
recovery: 'Request a new AutoPass invite from an admin.',
|
||||
severity: 'warning',
|
||||
},
|
||||
CAPABILITY_INVALID: {
|
||||
title: 'Capability invalid',
|
||||
recovery: 'The connection grant failed HMAC verification. Request a new AutoPass invite.',
|
||||
severity: 'danger',
|
||||
},
|
||||
CAPABILITY_EXPIRED: {
|
||||
title: 'Capability expired',
|
||||
recovery: 'This invite grant has expired. Ask an admin for a new AutoPass invite.',
|
||||
severity: 'warning',
|
||||
},
|
||||
CAPABILITY_SPENT: {
|
||||
title: 'Capability already used',
|
||||
recovery: 'This single-use grant was already redeemed. Request a new invite.',
|
||||
severity: 'warning',
|
||||
},
|
||||
ADMIN_PROOF_FAILED: {
|
||||
title: 'Admin authentication failed',
|
||||
recovery: 'Check that SERVER_SEED matches this server public key (64 hex characters).',
|
||||
severity: 'danger',
|
||||
},
|
||||
AUTOPASS_PAIR_FAILED: {
|
||||
title: 'AutoPass pairing failed',
|
||||
recovery: 'Verify the invite string and network, then try again or request a new invite.',
|
||||
severity: 'warning',
|
||||
},
|
||||
AUTOPASS_UNAVAILABLE: {
|
||||
title: 'AutoPass unavailable',
|
||||
recovery: 'Install the autopass dependency on this client, or connect with public key + capability.',
|
||||
severity: 'danger',
|
||||
},
|
||||
|
||||
UNKNOWN_METHOD: {
|
||||
title: 'Unsupported operation',
|
||||
recovery: 'The peer server is missing this method. Update peardock server and reconnect.',
|
||||
@@ -250,15 +281,11 @@ function classifyDockerMessage(message, method) {
|
||||
}
|
||||
}
|
||||
if (
|
||||
err?.code === 'CONTAINER_NAME_CONFLICT' ||
|
||||
(/container name|already in use by container|name .* already|already exists/i.test(lower) &&
|
||||
/use|conflict|taken|exists|replace/i.test(lower))
|
||||
/container name|already in use by container|name .* already|already exists/i.test(lower) &&
|
||||
/use|conflict|taken|exists|replace/i.test(lower)
|
||||
) {
|
||||
return {
|
||||
code:
|
||||
err?.code === 'CONTAINER_NAME_CONFLICT'
|
||||
? 'CONTAINER_NAME_CONFLICT'
|
||||
: 'DOCKER_CONFLICT',
|
||||
code: 'CONTAINER_NAME_CONFLICT',
|
||||
title: 'Container name already taken',
|
||||
message: m,
|
||||
recovery:
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Persistent client DHT identity for stable peerId across reconnects.
|
||||
* Stored at ~/.config/peardock/identity.json (mode 0600).
|
||||
*/
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import crypto from 'crypto'
|
||||
import DHT from 'hyperdht'
|
||||
import b4a from 'b4a'
|
||||
|
||||
const IDENTITY_VERSION = 1
|
||||
|
||||
/**
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getIdentityPath() {
|
||||
const home =
|
||||
process.env.PEARDOCK_HOME ||
|
||||
process.env.HOME ||
|
||||
process.env.USERPROFILE ||
|
||||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
|
||||
''
|
||||
return path.join(home, '.config', 'peardock', 'identity.json')
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ seed: Uint8Array, keyPair: { publicKey: Uint8Array, secretKey: Uint8Array }, publicKeyHex: string, seedHex: string }}
|
||||
*/
|
||||
export function loadOrCreateClientIdentity() {
|
||||
const filePath = getIdentityPath()
|
||||
let seedHex = null
|
||||
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
if (raw?.seedHex && /^[0-9a-fA-F]{64}$/.test(raw.seedHex)) {
|
||||
seedHex = String(raw.seedHex).toLowerCase()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// regenerate
|
||||
}
|
||||
|
||||
if (!seedHex) {
|
||||
seedHex = crypto.randomBytes(32).toString('hex')
|
||||
try {
|
||||
const dir = path.dirname(filePath)
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
version: IDENTITY_VERSION,
|
||||
seedHex,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
{ mode: 0o600 }
|
||||
)
|
||||
try {
|
||||
fs.chmodSync(filePath, 0o600)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} catch {
|
||||
// In-memory only if FS unavailable (browser-ish)
|
||||
}
|
||||
}
|
||||
|
||||
const seed = b4a.from(seedHex, 'hex')
|
||||
const keyPair = DHT.keyPair(seed)
|
||||
const publicKeyHex = b4a.toString(keyPair.publicKey, 'hex')
|
||||
return { seed, keyPair, publicKeyHex, seedHex }
|
||||
}
|
||||
|
||||
/** Cached identity for process lifetime */
|
||||
let cached = null
|
||||
|
||||
export function getClientIdentity() {
|
||||
if (!cached) cached = loadOrCreateClientIdentity()
|
||||
return cached
|
||||
}
|
||||
+24
-8
@@ -45,13 +45,16 @@ export class ConnectionManager extends EventEmitter {
|
||||
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
* @param {{ alias?: string, skipReconnectReset?: boolean }} [meta]
|
||||
* @param {{
|
||||
* alias?: string,
|
||||
* skipReconnectReset?: boolean,
|
||||
* inviteToken?: string,
|
||||
* capability?: string,
|
||||
* adminSeed?: string,
|
||||
* setActive?: boolean,
|
||||
* }} [meta]
|
||||
* @returns {Promise<PearDockConnection>}
|
||||
*/
|
||||
/**
|
||||
* @param {string} publicKeyHex
|
||||
* @param {{ alias?: string, skipReconnectReset?: boolean, inviteToken?: string }} [meta]
|
||||
*/
|
||||
async connect(publicKeyHex, meta = {}) {
|
||||
const key = publicKeyHex.toLowerCase()
|
||||
const id = key.slice(0, 12)
|
||||
@@ -63,6 +66,9 @@ export class ConnectionManager extends EventEmitter {
|
||||
publicKeyHex: key,
|
||||
alias: meta.alias ?? prev?.alias ?? null,
|
||||
inviteToken: meta.inviteToken ?? prev?.inviteToken ?? null,
|
||||
capability: meta.capability ?? prev?.capability ?? null,
|
||||
// adminSeed is session-only; keep across reconnect within process
|
||||
adminSeed: meta.adminSeed ?? prev?.adminSeed ?? null,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
intentional: false,
|
||||
@@ -72,16 +78,21 @@ export class ConnectionManager extends EventEmitter {
|
||||
publicKeyHex: key,
|
||||
alias: meta.alias || null,
|
||||
inviteToken: meta.inviteToken || null,
|
||||
capability: meta.capability || null,
|
||||
adminSeed: meta.adminSeed || null,
|
||||
attempts: 0,
|
||||
timer: null,
|
||||
intentional: false,
|
||||
}
|
||||
if (meta.alias) entry.alias = meta.alias
|
||||
if (meta.inviteToken) entry.inviteToken = meta.inviteToken
|
||||
if (meta.capability) entry.capability = meta.capability
|
||||
if (meta.adminSeed) entry.adminSeed = meta.adminSeed
|
||||
this._reconnect.set(id, entry)
|
||||
}
|
||||
|
||||
const shouldActivate = meta.setActive !== false
|
||||
const recon = this._reconnect.get(id)
|
||||
|
||||
if (this.connections.has(id)) {
|
||||
const existing = this.connections.get(id)
|
||||
@@ -95,10 +106,12 @@ export class ConnectionManager extends EventEmitter {
|
||||
|
||||
const conn = new PearDockConnection(key, {
|
||||
timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS,
|
||||
inviteToken: meta.inviteToken || undefined,
|
||||
inviteToken: meta.inviteToken || recon?.inviteToken || undefined,
|
||||
capability: meta.capability || recon?.capability || undefined,
|
||||
adminSeed: meta.adminSeed || recon?.adminSeed || undefined,
|
||||
})
|
||||
if (meta.alias) conn.alias = meta.alias
|
||||
else if (this._reconnect.get(id)?.alias) conn.alias = this._reconnect.get(id).alias
|
||||
else if (recon?.alias) conn.alias = recon.alias
|
||||
|
||||
conn.on('message', (msg) => this.emit('message', msg, conn))
|
||||
conn.on('health', (info) => this.emit('health', info, conn))
|
||||
@@ -120,7 +133,6 @@ export class ConnectionManager extends EventEmitter {
|
||||
}
|
||||
this.connections.set(id, conn)
|
||||
this.persist()
|
||||
// Boot restore dials all peers with setActive:false so last-known wins later
|
||||
if (shouldActivate) this.setActive(id)
|
||||
this._ensureHealthLoop()
|
||||
this.emit('connect', conn)
|
||||
@@ -239,6 +251,8 @@ export class ConnectionManager extends EventEmitter {
|
||||
await this.connect(entry.publicKeyHex, {
|
||||
alias: entry.alias || undefined,
|
||||
inviteToken: entry.inviteToken || undefined,
|
||||
capability: entry.capability || undefined,
|
||||
adminSeed: entry.adminSeed || undefined,
|
||||
skipReconnectReset: true,
|
||||
// Restore last-active peer when it comes back; don't steal active on
|
||||
// background peer reconnect unless nothing is active
|
||||
@@ -354,6 +368,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
publicKeyHex: key,
|
||||
alias: conn.alias || recon?.alias || serializable[id]?.alias || null,
|
||||
inviteToken: recon?.inviteToken || serializable[id]?.inviteToken || null,
|
||||
capability: recon?.capability || serializable[id]?.capability || null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +379,7 @@ export class ConnectionManager extends EventEmitter {
|
||||
publicKeyHex: recon.publicKeyHex,
|
||||
alias: recon.alias || null,
|
||||
inviteToken: recon.inviteToken || null,
|
||||
capability: recon.capability || null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-5
@@ -44,7 +44,7 @@ export function getPeersCacheDir() {
|
||||
* Normalize one peer entry.
|
||||
* @param {object|string} value
|
||||
* @param {string} [idHint]
|
||||
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }|null}
|
||||
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }|null}
|
||||
*/
|
||||
export function normalizePeerEntry(value, idHint = '') {
|
||||
if (!value) return null
|
||||
@@ -56,6 +56,7 @@ export function normalizePeerEntry(value, idHint = '') {
|
||||
publicKeyHex,
|
||||
alias: null,
|
||||
inviteToken: null,
|
||||
capability: null,
|
||||
}
|
||||
}
|
||||
const publicKeyHex = String(
|
||||
@@ -65,11 +66,19 @@ export function normalizePeerEntry(value, idHint = '') {
|
||||
.toLowerCase()
|
||||
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
|
||||
const id = String(idHint || value.id || publicKeyHex.slice(0, 12)).slice(0, 12)
|
||||
// Prefer capability; migrate inviteToken if it looks like an HMAC grant
|
||||
let capability = value.capability || null
|
||||
let inviteToken = value.inviteToken || null
|
||||
if (!capability && inviteToken && String(inviteToken).includes('.')) {
|
||||
capability = inviteToken
|
||||
inviteToken = null
|
||||
}
|
||||
return {
|
||||
id,
|
||||
publicKeyHex,
|
||||
alias: value.alias || null,
|
||||
inviteToken: value.inviteToken || null,
|
||||
inviteToken,
|
||||
capability,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +106,7 @@ export function parsePeersPayload(raw) {
|
||||
? parsed.peers
|
||||
: parsed
|
||||
|
||||
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>} */
|
||||
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }>} */
|
||||
const out = {}
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
// Skip meta keys if someone stored a flat object with version
|
||||
@@ -108,6 +117,7 @@ export function parsePeersPayload(raw) {
|
||||
publicKeyHex: entry.publicKeyHex,
|
||||
alias: entry.alias,
|
||||
inviteToken: entry.inviteToken,
|
||||
capability: entry.capability,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -115,7 +125,7 @@ export function parsePeersPayload(raw) {
|
||||
|
||||
/**
|
||||
* Build on-disk payload.
|
||||
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null }>} peersMap
|
||||
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null, capability?: string|null }>} peersMap
|
||||
* @param {{ activePeerId?: string|null }} [opts]
|
||||
*/
|
||||
export function buildPeersPayload(peersMap, opts = {}) {
|
||||
@@ -127,6 +137,7 @@ export function buildPeersPayload(peersMap, opts = {}) {
|
||||
publicKeyHex: entry.publicKeyHex,
|
||||
alias: entry.alias,
|
||||
inviteToken: entry.inviteToken,
|
||||
capability: entry.capability,
|
||||
}
|
||||
}
|
||||
const payload = {
|
||||
@@ -352,7 +363,7 @@ export function clearPeers() {
|
||||
|
||||
/**
|
||||
* List form used by ConnectionManager.loadSaved()
|
||||
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
|
||||
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null, capability: string|null }>}
|
||||
*/
|
||||
export function listSavedPeers() {
|
||||
const map = loadPeers()
|
||||
@@ -361,6 +372,7 @@ export function listSavedPeers() {
|
||||
publicKeyHex: value.publicKeyHex,
|
||||
alias: value.alias || null,
|
||||
inviteToken: value.inviteToken || null,
|
||||
capability: value.capability || null,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user