import { spawn } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { createServer } from 'node:http'; import { createConnection } from 'node:net'; import * as os from 'node:os'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { invokeHostControlFromStatus, type IHostSupervisorHostStatus, } from '@open-matrix/host-control'; import { readRegistryCredential, removeRegistryCredential, saveRegistryCredential, type IRegistryCredential, } from '../utils/auth-store.js'; import { readHiveCastHostLink, readHiveCastLocalOwnerTransport, readHiveCastLinkStatus, ensureHiveCastInstallIdentity, removeHiveCastCredentialFiles, removeHiveCastHostLink, saveHiveCastHostLink, type IHiveCastLinkStatus, type IHiveCastHostLinkRecord, type IHiveCastNatsConfig, type IHiveCastNatsCredentials, } from '../utils/hivecast-link-store.js'; export interface IAuthContextOptions { readonly cwd?: string; readonly registry?: string; readonly credentialsPath?: string; readonly home?: string; } export interface ILoginCommandOptions extends IAuthContextOptions { readonly username: string; readonly token: string; readonly email?: string; readonly expiresAt?: string; } export interface ILoginCommandResult { readonly registry: string; readonly username: string; readonly credentialsPath: string; } export interface ILogoutCommandResult { readonly registry: string; readonly removed: boolean; readonly credentialsPath: string; } export interface IWhoamiCommandResult { readonly registry: string; readonly authenticated: boolean; readonly username?: string; readonly email?: string; readonly expiresAt?: string; } export interface IHiveCastLoginCommandOptions extends IAuthContextOptions { readonly cloudUrl?: string; readonly setupCode?: string; readonly routeKey?: string; readonly device?: boolean; readonly hostName?: string; readonly callbackUrl?: string; readonly pairNonce?: string; readonly localPort?: number; readonly waitForCallback?: boolean; readonly noOpen?: boolean; readonly forceRelink?: boolean; readonly callbackTimeoutMs?: number; readonly pollTimeoutMs?: number; readonly openApprovalUrl?: (url: string) => void; readonly onSetupUrl?: (setupUrl: string) => void; readonly onDeviceStart?: (challenge: IHiveCastDeviceChallenge) => void; readonly onProgress?: (event: IHiveCastLoginProgressEvent) => void; } export interface IHiveCastLoginProgressEvent { readonly phase: 'link' | 'namespace' | 'ready'; readonly message: string; } export interface IHiveCastLoginCommandResult { readonly mode: 'approval-required' | 'linked'; readonly linked: boolean; readonly alreadyLinked?: boolean; readonly cloudUrl: string; readonly setupUrl?: string; readonly deviceCode?: string; readonly userCode?: string; readonly verificationUri?: string; readonly verificationUriComplete?: string; readonly expiresIn?: number; readonly matrixHome?: string; readonly linkPath?: string; readonly hostConfigPath?: string; readonly natsCredentialsPath?: string; readonly hostLinkId?: string; readonly hostId?: string; readonly authorityRoot?: string; readonly routeKey?: string; readonly publicNamespace?: string; readonly spaceId?: string; readonly hostName?: string; readonly deviceSlug?: string; readonly authorityCoordinator?: boolean; readonly linkedAt?: string; readonly hostConfigReload?: IHiveCastHostConfigReloadResult; } export interface IHiveCastLogoutCommandResult { readonly removed: boolean; readonly cloudRevoke?: IHiveCastCloudRevokeResult; readonly hostConfigReset: boolean; readonly matrixHome: string; readonly linkPath: string; readonly natsCredentialsPath: string; readonly hostConfigReload?: IHiveCastHostConfigReloadResult; } export interface IHiveCastRefreshCredentialsCommandResult { readonly refreshed: boolean; readonly matrixHome: string; readonly linkPath: string; readonly natsCredentialsPath: string; readonly currentCredentialsExpiresAt?: string; readonly currentCredentialsSecondsRemaining?: number; readonly refreshThresholdSeconds?: number; readonly hostLinkId?: string; readonly hostId?: string; readonly authorityRoot?: string; readonly routeKey?: string; readonly publicNamespace?: string; readonly spaceId?: string; readonly hostName?: string; readonly deviceSlug?: string; readonly authorityCoordinator?: boolean; readonly hostConfigReload?: IHiveCastHostConfigReloadResult; } export interface IHiveCastWhoamiCommandResult { readonly linked: boolean; readonly matrixHome: string; readonly cloudUrl?: string; readonly hostLinkId?: string; readonly authorityRoot?: string; readonly routeKey?: string; readonly publicNamespace?: string; readonly spaceId?: string; readonly principalId?: string; readonly hostName?: string; readonly deviceSlug?: string; readonly linkedAt?: string; } export interface IHiveCastHostConfigReloadResult { readonly attempted: boolean; readonly applied: boolean; readonly statusPath: string; readonly error?: string; } interface ITokenExchangeResponse { readonly ok?: unknown; readonly root?: unknown; readonly authorityRoot?: unknown; readonly deviceRegistryRoot?: unknown; readonly routeKey?: unknown; readonly publicNamespace?: unknown; readonly spaceId?: unknown; readonly principalId?: unknown; readonly hostName?: unknown; readonly deviceSlug?: unknown; readonly authorityCoordinator?: unknown; readonly hostLinkToken?: unknown; readonly hostLinkId?: unknown; readonly hostId?: unknown; readonly hostLink?: unknown; readonly device?: unknown; readonly nats?: unknown; readonly credentials?: unknown; readonly error?: unknown; } export interface IHiveCastDeviceChallenge { readonly deviceCode: string; readonly userCode: string; readonly verificationUri: string; readonly verificationUriComplete?: string; readonly expiresIn: number; readonly interval: number; } interface IDeviceStartResponse { readonly ok?: unknown; readonly deviceCode?: unknown; readonly userCode?: unknown; readonly verificationUri?: unknown; readonly verificationUriComplete?: unknown; readonly expiresIn?: unknown; readonly interval?: unknown; readonly error?: unknown; } interface IDevicePollResponse { readonly ok?: unknown; readonly status?: unknown; readonly interval?: unknown; readonly error?: unknown; } interface IPairStartResponse { readonly ok?: unknown; readonly pairRequestId?: unknown; readonly approvalUrl?: unknown; readonly expiresIn?: unknown; readonly error?: unknown; } interface INormalizedSetupExchange { readonly authorityRoot: string; readonly deviceRegistryRoot?: string; readonly hostLinkId?: string; readonly hostId?: string; readonly routeKey?: string; readonly publicNamespace?: string; readonly spaceId?: string; readonly principalId?: string; readonly hostName?: string; readonly deviceSlug?: string; readonly authorityCoordinator?: boolean; readonly hostLinkToken?: string; readonly nats: IHiveCastNatsConfig; readonly credentials: IHiveCastNatsCredentials; } interface IHiveCastCloudRevokeResult { readonly attempted: boolean; readonly revoked: boolean; readonly skippedReason?: string; readonly error?: string; } export interface IHiveCastLogoutCommandOptions extends IAuthContextOptions { readonly localOnly?: boolean; readonly revokeCloudLink?: boolean; readonly all?: boolean; } export interface IHiveCastRefreshCredentialsCommandOptions extends IAuthContextOptions { readonly ifExpiringWithinSeconds?: number; readonly authorityCoordinator?: boolean; } interface IHostConfigReloadExpectation { readonly root: string; readonly authorityRoot?: string; readonly routeKey?: string | null; readonly publicNamespace?: string | null; readonly spaceId?: string | null; readonly forceReload?: boolean; readonly label?: 'linked' | 'local-owner'; } function hostConfigExpectationForLink( matrixHome: string, record: IHiveCastHostLinkRecord, forceReload: boolean, ): IHostConfigReloadExpectation { return { root: record.authorityRoot, authorityRoot: record.authorityRoot, routeKey: record.routeKey ?? null, publicNamespace: record.publicNamespace ?? null, spaceId: record.spaceId ?? null, forceReload, label: 'linked', }; } function reportLoginProgress( options: IHiveCastLoginCommandOptions, phase: IHiveCastLoginProgressEvent['phase'], message: string, ): void { options.onProgress?.({ phase, message }); } function assertRunningHostConfigReloadApplied( result: IHiveCastHostConfigReloadResult, expectation: IHostConfigReloadExpectation, ): void { if (!result.attempted || result.applied) { return; } const expected = [ `expectedRoot=${expectation.root}`, ...(expectation.authorityRoot ? [`expectedAuthorityRoot=${expectation.authorityRoot}`] : []), `statusPath=${result.statusPath}`, ].join(' '); throw new Error( `HIVECAST_LINK_HOST_CONFIG_RELOAD_FAILED: running Host did not apply ${expectation.label ?? 'linked'} config` + `${result.error ? `: ${result.error}` : ''}; ${expected}`, ); } function localOwnerTransportForExpectation( matrixHome: string, ): { readonly root: string; readonly authorityRoot?: string } | null { const localOwnerTransport = readHiveCastLocalOwnerTransport(process.cwd(), matrixHome); if (localOwnerTransport) { return localOwnerTransport; } const hostConfig = readJsonRecordFile(path.join(matrixHome, 'host.json')); const transport = asRecord(hostConfig?.transport); const root = readOptionalString(transport?.root); if (!root) { return null; } const authorityRoot = readOptionalString(transport?.authorityRoot); return { root, ...(authorityRoot ? { authorityRoot } : {}), }; } const DEFAULT_HIVECAST_CLOUD_URL = 'https://hivecast.ai'; const DEFAULT_NATS_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; const HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS = 60_000; const HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS = 90_000; const HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS = 20_000; const LOCAL_OWNER_NATS_START_TIMEOUT_MS = 10_000; function readOptionalString(value: unknown): string | undefined { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; } function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as Record; } function decodeJwtPayload(token: string): Record | null { const parts = token.split('.'); if (parts.length !== 3 || !parts[1]) { return null; } try { return asRecord(JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'))); } catch { return null; } } function readJwtExpirationSeconds(token: string): number | null { const payload = decodeJwtPayload(token); const exp = payload?.exp; return typeof exp === 'number' && Number.isFinite(exp) ? exp : null; } function readCredentialsExpirationSeconds(filePath: string): number | null { if (!fs.existsSync(filePath)) { return null; } let parsed: Record | null; try { parsed = asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8'))); } catch { return null; } const jwt = readOptionalString(parsed?.jwt); return jwt ? readJwtExpirationSeconds(jwt) : null; } export function normalizeNonNegativeInteger(value: string | undefined, label: string): number | undefined { if (value === undefined) { return undefined; } if (!/^\d+$/.test(value.trim())) { throw new Error(`${label} must be a non-negative integer number of seconds`); } return Number.parseInt(value, 10); } async function readJsonResponse( response: Response, label: string, ): Promise { const text = await response.text(); if (!text.trim()) { throw new Error(`${label}: empty response body (HTTP ${response.status})`); } try { return JSON.parse(text) as T; } catch (error: unknown) { const preview = text.slice(0, 240).replace(/\s+/g, ' ').trim(); const reason = error instanceof Error ? error.message : String(error); throw new Error(`${label}: invalid JSON response (HTTP ${response.status}): ${reason}${preview ? `: ${preview}` : ''}`); } } function normalizeCloudUrl(raw: string | undefined): string { const value = raw?.trim() || process.env.HIVECAST_PLATFORM_URL || DEFAULT_HIVECAST_CLOUD_URL; const parsed = new URL(value); if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { throw new Error(`HIVECAST_LOGIN_INVALID_CLOUD_URL: unsupported protocol ${parsed.protocol}`); } return parsed.origin; } function openBrowser(url: string): void { const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open'; const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url]; const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true, }); child.on('error', () => { // Best effort: the setup URL is printed by the caller. }); child.unref(); } function readJsonRecordFile(filePath: string): Record | null { if (!fs.existsSync(filePath)) { return null; } try { return asRecord(JSON.parse(fs.readFileSync(filePath, 'utf8'))); } catch { return null; } } function hostStatusPath(matrixHome: string): string { return path.join(matrixHome, 'host.status.json'); } function hostRuntimeRecordsDir(matrixHome: string): string { return path.join(matrixHome, 'runtimes'); } function readHostStatusForSupervisor(statusPath: string): IHostSupervisorHostStatus | null { const parsed = readJsonRecordFile(statusPath); if (!parsed || readOptionalString(parsed.status) !== 'running') { return null; } const transport = asRecord(parsed.transport); const nats = asRecord(transport?.nats); const root = readOptionalString(transport?.root); const natsUrl = readOptionalString(nats?.url); if (!root || !natsUrl) { return null; } const credentialsRef = readOptionalString(nats?.credentialsRef); const supervisorMount = readOptionalString(parsed.supervisorMount); return { ...(supervisorMount ? { supervisorMount } : {}), transport: { root, nats: { url: natsUrl, ...(credentialsRef ? { credentialsRef } : {}), }, }, }; } function hostStatusMatchesExpectation( statusPath: string, expectation: IHostConfigReloadExpectation, ): boolean { const parsed = readJsonRecordFile(statusPath); const transport = asRecord(parsed?.transport); if (!transport) { return false; } const root = readOptionalString(transport.root); const authorityRoot = readOptionalString(transport.authorityRoot) ?? root; const routeKey = readOptionalString(transport.routeKey); const publicNamespace = readOptionalString(transport.publicNamespace); const spaceId = readOptionalString(transport.spaceId); if (root !== expectation.root) { return false; } if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) { return false; } if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) { return false; } if ( expectation.publicNamespace !== undefined && publicNamespace !== (expectation.publicNamespace ?? undefined) ) { return false; } if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) { return false; } return true; } async function waitForHostStatusExpectation( statusPath: string, expectation: IHostConfigReloadExpectation, timeoutMs = HOST_CONFIG_STATUS_APPLY_TIMEOUT_MS, ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { if (hostStatusMatchesExpectation(statusPath, expectation)) { return true; } await delay(100); } return false; } function readHostRuntimeRecords(matrixHome: string): Record[] { const recordsDir = hostRuntimeRecordsDir(matrixHome); if (!fs.existsSync(recordsDir)) { return []; } const records: Record[] = []; for (const entry of fs.readdirSync(recordsDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; const record = readJsonRecordFile(path.join(recordsDir, entry.name, 'runtime.json')); if (record) { records.push(record); } } return records; } function isProcessAliveForRuntime(pid: unknown): boolean { if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) { return false; } try { process.kill(pid, 0); return true; } catch (error: unknown) { return asRecord(error)?.code === 'EPERM'; } } function positivePort(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0 || value > 65535) { return undefined; } return value; } function positiveInteger(value: unknown): number | undefined { if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { return undefined; } return value; } function loopbackUrlPort(raw: string | undefined): number | undefined { if (!raw) return undefined; try { const parsed = new URL(raw); if (!['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname)) { return undefined; } const port = Number.parseInt(parsed.port, 10); return Number.isFinite(port) && port > 0 && port <= 65535 ? port : undefined; } catch { return undefined; } } async function isTcpPortOpen(host: string, port: number): Promise { return await new Promise((resolve) => { const socket = createConnection({ host, port }); const done = (open: boolean): void => { socket.removeAllListeners(); socket.destroy(); resolve(open); }; socket.setTimeout(500); socket.once('connect', () => done(true)); socket.once('timeout', () => done(false)); socket.once('error', () => done(false)); }); } async function waitForTcpPort(host: string, port: number, timeoutMs: number): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { if (await isTcpPortOpen(host, port)) { return true; } await delay(100); } return false; } function resolveHostRelativePath(matrixHome: string, raw: string | undefined, defaultRelative: string): string { const value = raw ?? defaultRelative; return path.isAbsolute(value) ? value : path.resolve(matrixHome, value); } function writeLocalOwnerNatsConfig(matrixHome: string, nats: Record): { readonly binaryPath: string; readonly confFile: string; readonly pidFile: string; readonly dataDir: string; readonly port: number; readonly wsPort: number; } { const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url)); const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl)); const maxPayloadBytes = positiveInteger(nats.maxPayloadBytes) ?? DEFAULT_NATS_MAX_PAYLOAD_BYTES; if (!port || !wsPort) { throw new Error('Local owner NATS transport requires loopback client and websocket ports'); } const dataDir = resolveHostRelativePath(matrixHome, readOptionalString(nats.dataDir), path.join('nats', 'host-default')); const pidFile = resolveHostRelativePath( matrixHome, readOptionalString(nats.pidFile), path.join('nats', 'host-default', 'nats-server.pid'), ); const binaryPath = resolveHostRelativePath(matrixHome, readOptionalString(nats.binaryPath), path.join('bin', 'nats-server')); const confFile = path.join(dataDir, 'nats-server.conf'); fs.mkdirSync(dataDir, { recursive: true }); const escapedStoreDir = dataDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); fs.writeFileSync(confFile, [ 'host: "127.0.0.1"', `port: ${port}`, `max_payload: ${maxPayloadBytes}`, 'jetstream {', ` store_dir: "${escapedStoreDir}"`, '}', 'websocket {', ' host: "127.0.0.1"', ` port: ${wsPort}`, ' no_tls: true', '}', '', ].join('\n'), 'utf8'); return { binaryPath, confFile, pidFile, dataDir, port, wsPort }; } async function ensureLocalOwnerNatsDependency(matrixHome: string): Promise { const config = readJsonRecordFile(path.join(matrixHome, 'host.json')); const transport = asRecord(config?.transport); const nats = asRecord(transport?.nats); if (!transport || !nats) return; const root = readOptionalString(transport.root); const mode = readOptionalString(nats.mode); const credentialsRef = readOptionalString(nats.credentialsRef); if (!root || mode !== 'external' || credentialsRef) { return; } const port = positivePort(nats.port) ?? loopbackUrlPort(readOptionalString(nats.url)); const wsPort = positivePort(nats.wsPort) ?? loopbackUrlPort(readOptionalString(nats.wsUrl)); if (!port || !wsPort) return; if (await isTcpPortOpen('127.0.0.1', port)) { return; } const paths = writeLocalOwnerNatsConfig(matrixHome, nats); if (!fs.existsSync(paths.binaryPath)) { throw new Error(`Local owner NATS binary is missing: ${paths.binaryPath}`); } if (fs.existsSync(paths.pidFile)) { const pid = Number.parseInt(fs.readFileSync(paths.pidFile, 'utf8').trim(), 10); if (Number.isInteger(pid) && pid > 0 && isProcessAliveForRuntime(pid)) { throw new Error(`Local owner NATS pid ${pid} is alive but port ${port} is not reachable`); } fs.rmSync(paths.pidFile, { force: true }); } fs.mkdirSync(path.dirname(paths.pidFile), { recursive: true }); fs.mkdirSync(path.join(matrixHome, 'logs'), { recursive: true }); const stdout = fs.openSync(path.join(matrixHome, 'logs', 'nats.stdout.log'), 'a'); const stderr = fs.openSync(path.join(matrixHome, 'logs', 'nats.stderr.log'), 'a'); const child = spawn(paths.binaryPath, ['-c', paths.confFile], { detached: true, stdio: ['ignore', stdout, stderr], windowsHide: true, }); if (!child.pid) { throw new Error('Local owner NATS did not provide a process id'); } fs.writeFileSync(paths.pidFile, `${child.pid}\n`, 'utf8'); child.unref(); const started = await waitForTcpPort('127.0.0.1', port, LOCAL_OWNER_NATS_START_TIMEOUT_MS); if (!started) { throw new Error(`Local owner NATS pid ${child.pid} did not open port ${port} within ${LOCAL_OWNER_NATS_START_TIMEOUT_MS}ms`); } } function runtimeRecordMatchesExpectation( record: Record, expectation: IHostConfigReloadExpectation, ): boolean { const metadata = asRecord(record.metadata); const transport = asRecord(metadata?.transport); const root = readOptionalString(transport?.root) ?? readOptionalString(record.runtimeWireRoot); const authorityRoot = readOptionalString(transport?.authorityRoot) ?? root; const routeKey = readOptionalString(transport?.routeKey); const publicNamespace = readOptionalString(transport?.publicNamespace); const spaceId = readOptionalString(transport?.spaceId); if (root !== expectation.root) { return false; } if (expectation.authorityRoot !== undefined && authorityRoot !== expectation.authorityRoot) { return false; } if (expectation.routeKey !== undefined && routeKey !== (expectation.routeKey ?? undefined)) { return false; } if ( expectation.publicNamespace !== undefined && publicNamespace !== (expectation.publicNamespace ?? undefined) ) { return false; } if (expectation.spaceId !== undefined && spaceId !== (expectation.spaceId ?? undefined)) { return false; } return true; } function activeDefaultRuntimeRecordsReady( matrixHome: string, expectation: IHostConfigReloadExpectation, ): boolean { const activeDefaultRecords = readActiveDefaultRuntimeRecords(matrixHome); if (activeDefaultRecords.length === 0) { return true; } return activeDefaultRecords.every((record) => ( record.status === 'running' && isProcessAliveForRuntime(record.pid) && runtimeRecordMatchesExpectation(record, expectation) )); } function readActiveDefaultRuntimeRecords(matrixHome: string): Record[] { return readHostRuntimeRecords(matrixHome).filter((record) => { const metadata = asRecord(record.metadata); return metadata?.hivecastDefaultRuntimePolicy === 'active' && record.startup === 'auto'; }); } function hostConfigRuntimeApplyTimeoutMs(matrixHome: string): number { const activeRuntimeCount = readActiveDefaultRuntimeRecords(matrixHome).length; if (activeRuntimeCount === 0) { return HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS; } // Host Service starts persisted runtimes in dependency order and waits for // each control actor. The CLI reload waiter must scale with that contract. return Math.max( HOST_CONFIG_RUNTIME_APPLY_MIN_TIMEOUT_MS, activeRuntimeCount * HOST_CONFIG_RUNTIME_APPLY_PER_RUNTIME_MS, ); } async function waitForActiveDefaultRuntimeRecords( matrixHome: string, expectation: IHostConfigReloadExpectation, timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome), ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { if (activeDefaultRuntimeRecordsReady(matrixHome, expectation)) { return true; } await delay(100); } return false; } async function activeDefaultWebappOriginsReady(matrixHome: string): Promise { const webappOrigins = readActiveDefaultRuntimeRecords(matrixHome) .map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.origin)) .filter((origin): origin is string => origin !== undefined); if (webappOrigins.length === 0) { return true; } const probes = await Promise.all(webappOrigins.map((origin) => webappOriginReady(origin))); return probes.every(Boolean); } async function activeDefaultHostWebappRoutesReady(matrixHome: string, statusPath: string): Promise { const hostOrigin = readHostHttpOrigin(statusPath); if (!hostOrigin) { return false; } const routePrefixes = readActiveDefaultRuntimeRecords(matrixHome) .map((record) => readOptionalString(asRecord(asRecord(record.metadata)?.webapp)?.routePrefix)) .filter((routePrefix): routePrefix is string => routePrefix !== undefined); if (routePrefixes.length === 0) { return true; } const probes = await Promise.all(routePrefixes.map((routePrefix) => { const routeUrl = new URL(routePrefix, hostOrigin); return webappOriginReady(routeUrl.href); })); return probes.every(Boolean); } async function waitForActiveDefaultWebappOrigins( matrixHome: string, statusPath: string, timeoutMs = hostConfigRuntimeApplyTimeoutMs(matrixHome), ): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() <= deadline) { if ( await activeDefaultWebappOriginsReady(matrixHome) && await activeDefaultHostWebappRoutesReady(matrixHome, statusPath) ) { return true; } await delay(100); } return false; } async function describeActiveDefaultWebappReadiness(matrixHome: string, statusPath: string): Promise { const hostOrigin = readHostHttpOrigin(statusPath); const records = readActiveDefaultRuntimeRecords(matrixHome) .map((record) => { const webapp = asRecord(asRecord(record.metadata)?.webapp); return { runtimeId: readOptionalString(record.runtimeId) ?? 'unknown-runtime', routePrefix: readOptionalString(webapp?.routePrefix), origin: readOptionalString(webapp?.origin), }; }) .filter((record) => record.routePrefix || record.origin); const lines: string[] = []; for (const record of records) { const direct = record.origin ? await describeWebappUrl(record.origin) : 'direct=missing'; const routed = hostOrigin && record.routePrefix ? await describeWebappUrl(new URL(record.routePrefix, hostOrigin).href) : 'routed=missing'; lines.push(`${record.runtimeId} route=${record.routePrefix ?? 'none'} ${direct} ${routed}`); } return lines.slice(0, 12).join('; '); } async function describeWebappUrl(url: string): Promise { try { const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) }); const body = await response.text(); const assetUrls = webappAssetUrls(new URL(url), body); const firstAsset = assetUrls[0]; if (!firstAsset) { return `${url} html=${response.status} asset=none`; } try { const assetResponse = await fetch(firstAsset, { method: 'GET', signal: AbortSignal.timeout(1_500) }); await assetResponse.arrayBuffer(); return `${url} html=${response.status} asset=${assetResponse.status} assetUrl=${firstAsset.href}`; } catch (error) { return `${url} html=${response.status} asset=fetch-failed:${error instanceof Error ? error.message : String(error)}`; } } catch (error) { return `${url} html=fetch-failed:${error instanceof Error ? error.message : String(error)}`; } } function readHostHttpOrigin(statusPath: string): string | undefined { const parsed = readJsonRecordFile(statusPath); const http = asRecord(parsed?.http); return readOptionalString(http?.origin); } async function webappOriginReady(origin: string): Promise { let url: URL; try { url = new URL(origin); } catch { return false; } if (!['http:', 'https:'].includes(url.protocol)) { return false; } try { const response = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(1_500) }); const body = await response.text(); if (response.status < 200 || response.status >= 500) { return false; } const assetUrls = webappAssetUrls(url, body); if (assetUrls.length === 0) { return true; } const assetResponses = await Promise.all(assetUrls.map(async (assetUrl) => { try { const assetResponse = await fetch(assetUrl, { method: 'GET', signal: AbortSignal.timeout(1_500) }); await assetResponse.arrayBuffer(); return assetResponse.status >= 200 && assetResponse.status < 500; } catch { return false; } })); return assetResponses.every(Boolean); } catch { return false; } } function webappAssetUrls(origin: URL, body: string): URL[] { const urls: URL[] = []; const seen = new Set(); const assetPattern = /\b(?:src|href)=["']([^"']*assets\/[^"']+)["']/g; let match = assetPattern.exec(body); while (match) { const raw = match[1]; if (raw) { try { const url = new URL(raw, origin); if (url.origin === origin.origin && !seen.has(url.href)) { seen.add(url.href); urls.push(url); } } catch { // Ignore malformed asset references in readiness probing. } } match = assetPattern.exec(body); } return urls.slice(0, 8); } async function reloadRunningHostConfig( matrixHome: string, expectation: IHostConfigReloadExpectation, ): Promise { const statusPath = hostStatusPath(matrixHome); if ( !expectation.forceReload && hostStatusMatchesExpectation(statusPath, expectation) && activeDefaultRuntimeRecordsReady(matrixHome, expectation) ) { return { attempted: false, applied: true, statusPath }; } const status = readHostStatusForSupervisor(statusPath); if (!status) { return { attempted: false, applied: false, statusPath }; } try { await invokeHostControlFromStatus(status, 'config.reload', {}, 5_000); const statusApplied = await waitForHostStatusExpectation(statusPath, expectation); const applied = statusApplied; const expectationLabel = expectation.label ?? 'linked'; return { attempted: true, applied, statusPath, ...(applied ? {} : { error: statusApplied ? `Running Host applied ${expectationLabel} config but did not report success` : `Timed out waiting for running Host to apply ${expectationLabel} config` }), }; } catch (error) { return { attempted: true, applied: false, statusPath, error: error instanceof Error ? error.message : String(error), }; } } async function waitForLoopbackSetupCode( cloudUrl: string, options: IHiveCastLoginCommandOptions, ): Promise { const timeoutMs = options.callbackTimeoutMs ?? 10 * 60 * 1000; const pairNonce = randomBytes(18).toString('base64url'); return await new Promise((resolve, reject) => { let settled = false; const server = createServer((req, res) => { void (async () => { const requestUrl = new URL(req.url ?? '/', 'http://127.0.0.1'); if (requestUrl.pathname !== '/auth/callback') { res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }); res.end('Not found'); return; } const pairRequestId = readOptionalString(requestUrl.searchParams.get('pairRequestId')); const approvalCode = readOptionalString(requestUrl.searchParams.get('approvalCode')); const nonce = readOptionalString(requestUrl.searchParams.get('nonce')); if (!pairRequestId || !approvalCode) { res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }); res.end('

HiveCast login failed

Missing approval code.

'); return; } if (nonce !== pairNonce) { res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' }); res.end('

HiveCast login failed

Invalid approval nonce.

'); return; } const exchange = await exchangePairApprovalWithRequiredCodes(cloudUrl, pairRequestId, approvalCode); res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end('

HiveCast connected

You can return to the terminal.

'); settled = true; clearTimeout(timer); resolve(exchange); server.close(); })().catch((error: unknown) => { if (!settled) { settled = true; clearTimeout(timer); if (!res.headersSent) { res.writeHead(500, { 'content-type': 'text/html; charset=utf-8' }); res.end('

HiveCast login failed

Pairing exchange failed.

'); } server.close(); reject(error instanceof Error ? error : new Error(String(error))); } }); }); const timer = setTimeout(() => { if (settled) return; settled = true; server.close(); reject(new Error('HIVECAST_LOGIN_TIMEOUT: timed out waiting for browser approval')); }, timeoutMs); server.once('error', (err) => { if (settled) return; settled = true; clearTimeout(timer); reject(err); }); server.listen(options.localPort ?? 0, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address !== 'object') { clearTimeout(timer); settled = true; server.close(); reject(new Error('HIVECAST_LOGIN_CALLBACK_FAILED: failed to bind loopback callback')); return; } void startPairFlow(cloudUrl, { ...options, callbackUrl: `http://127.0.0.1:${address.port}/auth/callback`, pairNonce, }).then((pair) => { options.onSetupUrl?.(pair.approvalUrl); if (!options.noOpen) { (options.openApprovalUrl ?? openBrowser)(pair.approvalUrl); } }).catch((error: unknown) => { if (settled) return; settled = true; clearTimeout(timer); server.close(); reject(error instanceof Error ? error : new Error(String(error))); }); }); }); } function normalizeSetupExchange(value: ITokenExchangeResponse): INormalizedSetupExchange { const authorityRoot = readOptionalString(value.authorityRoot) ?? readOptionalString(value.root); if (!authorityRoot) { throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing authority root'); } const nats = asRecord(value.nats); const natsUrl = readOptionalString(nats?.url); if (!natsUrl) { throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: response is missing nats.url'); } const credentials = asRecord(value.credentials); const jwt = readOptionalString(credentials?.jwt); const seed = readOptionalString(credentials?.seed); if (!jwt || !seed) { throw new Error('HIVECAST_LOGIN_INVALID_EXCHANGE: credentials must contain device-scoped jwt+seed'); } const wsUrl = readOptionalString(nats?.wsUrl); const leafnodeUrl = readOptionalString(nats?.leafnodeUrl); const routeKey = readOptionalString(value.routeKey); const deviceRegistryRoot = readOptionalString(value.deviceRegistryRoot); const publicNamespace = readOptionalString(value.publicNamespace); const spaceId = readOptionalString(value.spaceId); const principalId = readOptionalString(value.principalId); const hostLink = asRecord(value.hostLink); const device = asRecord(value.device); const hostLinkId = readOptionalString(value.hostLinkId) ?? readOptionalString(hostLink?.id); const hostId = readOptionalString(value.hostId) ?? readOptionalString(hostLink?.hostId) ?? readOptionalString(device?.deviceCode); const hostName = readOptionalString(value.hostName) ?? readOptionalString(hostLink?.hostName); const deviceSlug = readOptionalString(value.deviceSlug) ?? readOptionalString(hostLink?.deviceSlug); const authorityCoordinator = typeof value.authorityCoordinator === 'boolean' ? value.authorityCoordinator : typeof hostLink?.authorityCoordinator === 'boolean' ? hostLink.authorityCoordinator : undefined; const hostLinkToken = readOptionalString(value.hostLinkToken) ?? readOptionalString(hostLink?.heartbeatToken) ?? readOptionalString(hostLink?.hostLinkToken); return { authorityRoot, ...(deviceRegistryRoot ? { deviceRegistryRoot } : {}), ...(hostLinkId ? { hostLinkId } : {}), ...(hostId ? { hostId } : {}), ...(routeKey ? { routeKey } : {}), ...(publicNamespace ? { publicNamespace } : {}), ...(spaceId ? { spaceId } : {}), ...(principalId ? { principalId } : {}), ...(hostName ? { hostName } : {}), ...(deviceSlug ? { deviceSlug } : {}), ...(typeof authorityCoordinator === 'boolean' ? { authorityCoordinator } : {}), ...(hostLinkToken ? { hostLinkToken } : {}), nats: { url: natsUrl, ...(wsUrl ? { wsUrl } : {}), ...(leafnodeUrl ? { leafnodeUrl } : {}), }, credentials: { jwt, seed }, }; } async function exchangeSetupCode(cloudUrl: string, code: string): Promise { const response = await fetch(`${cloudUrl}/api/token-exchange`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ code }), }); const parsed = await readJsonResponse(response, 'HiveCast setup exchange'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast setup exchange failed: HTTP ${response.status}`; throw new Error(`HIVECAST_LOGIN_EXCHANGE_FAILED: ${message}`); } return normalizeSetupExchange(parsed); } async function exchangePairApproval( cloudUrl: string, pairRequestId: string, approvalCode: string, ): Promise { const response = await fetch(`${cloudUrl}/_auth/pair/exchange`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pairRequestId, approvalCode }), }); const parsed = await readJsonResponse(response, 'HiveCast pair exchange'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast pair exchange failed: HTTP ${response.status}`; throw new Error(`HIVECAST_PAIR_EXCHANGE_FAILED: ${message}`); } return normalizeSetupExchange(parsed); } async function exchangePairApprovalWithRequiredCodes( cloudUrl: string, pairRequestId: string | undefined, approvalCode: string | undefined, ): Promise { if (!pairRequestId || !approvalCode) { throw new Error('HIVECAST_PAIR_APPROVAL_REQUIRED: missing pair approval code'); } return await exchangePairApproval(cloudUrl, pairRequestId, approvalCode); } async function startPairFlow( cloudUrl: string, options: IHiveCastLoginCommandOptions, ): Promise<{ readonly pairRequestId: string; readonly approvalUrl: string; readonly expiresIn: number }> { const localReturnUrl = readOptionalString(options.callbackUrl); const nonce = readOptionalString(options.pairNonce); const routeKey = readOptionalString(options.routeKey); const requestedHostName = readOptionalString(options.hostName) ?? os.hostname(); const { identity } = ensureHiveCastInstallIdentity({ cwd: options.cwd ?? process.cwd(), matrixHome: options.home, hostName: requestedHostName, }); const response = await fetch(`${cloudUrl}/_auth/pair/start`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hostId: identity.installId, hostName: identity.hostName ?? requestedHostName, authorityCoordinator: false, ...(localReturnUrl ? { localReturnUrl } : {}), ...(nonce ? { nonce } : {}), ...(routeKey ? { routeKey } : {}), }), }); const parsed = await readJsonResponse(response, 'HiveCast pair start'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast pairing start failed: HTTP ${response.status}`; throw new Error(`HIVECAST_PAIR_START_FAILED: ${message}`); } const pairRequestId = readOptionalString(parsed.pairRequestId); const approvalUrl = readOptionalString(parsed.approvalUrl); if (!pairRequestId || !approvalUrl) { throw new Error('HIVECAST_PAIR_INVALID_START: response is missing pairing challenge fields'); } const expiresIn = typeof parsed.expiresIn === 'number' && Number.isFinite(parsed.expiresIn) ? parsed.expiresIn : 180 * 24 * 60 * 60; return { pairRequestId, approvalUrl, expiresIn }; } function normalizeDeviceChallenge(value: IDeviceStartResponse): IHiveCastDeviceChallenge { const deviceCode = readOptionalString(value.deviceCode); const userCode = readOptionalString(value.userCode); const verificationUri = readOptionalString(value.verificationUri); const verificationUriComplete = readOptionalString(value.verificationUriComplete); const expiresIn = typeof value.expiresIn === 'number' && Number.isFinite(value.expiresIn) ? value.expiresIn : 180 * 24 * 60 * 60; const interval = typeof value.interval === 'number' && Number.isFinite(value.interval) && value.interval > 0 ? value.interval : 2; if (!deviceCode || !userCode || !verificationUri) { throw new Error('HIVECAST_DEVICE_INVALID_START: response is missing device challenge fields'); } return { deviceCode, userCode, verificationUri, ...(verificationUriComplete ? { verificationUriComplete } : {}), expiresIn, interval, }; } async function startDeviceFlow( cloudUrl: string, options: IHiveCastLoginCommandOptions, ): Promise { const requestedHostName = readOptionalString(options.hostName) ?? os.hostname(); const { identity } = ensureHiveCastInstallIdentity({ cwd: options.cwd ?? process.cwd(), matrixHome: options.home, hostName: requestedHostName, }); let lastError: unknown; for (let attempt = 1; attempt <= 4; attempt += 1) { try { const response = await fetch(`${cloudUrl}/_auth/device/start`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ hostId: identity.installId, hostName: identity.hostName ?? requestedHostName, authorityCoordinator: false, ...(readOptionalString(options.routeKey) ? { routeKey: readOptionalString(options.routeKey) } : {}), }), }); const parsed = await readJsonResponse(response, 'HiveCast device start'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast device start failed: HTTP ${response.status}`; throw new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`); } return normalizeDeviceChallenge(parsed); } catch (error: unknown) { lastError = error; const message = error instanceof Error ? error.message : String(error); if (!isRetryableDeviceStartError(message) || attempt === 4) { break; } await delay(attempt * 500); } } const message = lastError instanceof Error ? lastError.message : String(lastError ?? 'unknown error'); throw message.startsWith('HIVECAST_DEVICE_START_FAILED:') ? new Error(message) : new Error(`HIVECAST_DEVICE_START_FAILED: ${message}`); } function isRetryableDeviceStartError(message: string): boolean { return /\bHTTP (502|503|504)\b/i.test(message) || /empty response body/i.test(message) || /fetch failed|network|timeout/i.test(message); } async function pollDeviceApproval( cloudUrl: string, challenge: IHiveCastDeviceChallenge, timeoutMs: number, ): Promise { const deadline = Date.now() + timeoutMs; let intervalMs = Math.max(1, challenge.interval) * 1000; while (Date.now() < deadline) { const response = await fetch(`${cloudUrl}/_auth/device/poll`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceCode: challenge.deviceCode }), }); let parsed: IDevicePollResponse; try { parsed = await readJsonResponse(response, 'HiveCast device poll'); } catch (error: unknown) { const remainingMs = deadline - Date.now(); if (response.ok && remainingMs > 1_000) { await delay(Math.min(1_000, remainingMs)); continue; } throw error; } const status = readOptionalString(parsed.status); if ((!response.ok || parsed.ok !== true) && status !== 'namespace_required') { const message = readOptionalString(parsed.error) ?? `HiveCast device poll failed: HTTP ${response.status}`; throw new Error(`HIVECAST_DEVICE_POLL_FAILED: ${message}`); } if (status === 'approved') { return; } if (status !== 'authorization_pending' && status !== 'namespace_required') { throw new Error(`HIVECAST_DEVICE_POLL_FAILED: unexpected status ${status ?? 'unknown'}`); } if (typeof parsed.interval === 'number' && Number.isFinite(parsed.interval) && parsed.interval > 0) { intervalMs = parsed.interval * 1000; } await delay(Math.min(intervalMs, Math.max(0, deadline - Date.now()))); } throw new Error('HIVECAST_DEVICE_TIMEOUT: timed out waiting for device approval'); } async function exchangeDeviceCode( cloudUrl: string, deviceCode: string, ): Promise { const response = await fetch(`${cloudUrl}/_auth/device/exchange`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ deviceCode }), }); const parsed = await readJsonResponse(response, 'HiveCast device exchange'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast device exchange failed: HTTP ${response.status}`; throw new Error(`HIVECAST_DEVICE_EXCHANGE_FAILED: ${message}`); } return normalizeSetupExchange(parsed); } function deviceChallengeTimeoutMs(challenge: IHiveCastDeviceChallenge): number { if (Number.isFinite(challenge.expiresIn) && challenge.expiresIn > 0) { return (challenge.expiresIn * 1000) + 5_000; } return 180 * 24 * 60 * 60 * 1000; } async function delay(ms: number): Promise { if (ms <= 0) { return; } await new Promise((resolve) => { setTimeout(resolve, ms); }); } export async function loginCommand(options: ILoginCommandOptions): Promise { if (!options.username.trim()) { throw new Error('MX_AUTH_REQUIRED: username is required for login'); } if (!options.token.trim()) { throw new Error('MX_AUTH_REQUIRED: token is required for login'); } const cwd = options.cwd ?? process.cwd(); const credential: IRegistryCredential = { username: options.username.trim(), token: options.token.trim(), email: options.email?.trim() || undefined, expiresAt: options.expiresAt?.trim() || undefined, }; const { registry, credentialsPath } = saveRegistryCredential(cwd, credential, { registry: options.registry, credentialsPath: options.credentialsPath, }); return { registry, username: credential.username, credentialsPath, }; } export async function logoutCommand(options: IAuthContextOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); return removeRegistryCredential(cwd, { registry: options.registry, credentialsPath: options.credentialsPath, }); } export async function whoamiCommand(options: IAuthContextOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const { registry, credential } = readRegistryCredential(cwd, { registry: options.registry, credentialsPath: options.credentialsPath, }); if (!credential) { return { registry, authenticated: false }; } return { registry, authenticated: true, username: credential.username, email: credential.email, expiresAt: credential.expiresAt, }; } export async function hiveCastLoginCommand( options: IHiveCastLoginCommandOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const cloudUrl = normalizeCloudUrl(options.cloudUrl); const existing = readHiveCastHostLink(cwd, options.home); if (existing.record && options.forceRelink !== true) { let activeRecord = existing.record; let activePaths = existing.paths; reportLoginProgress(options, 'link', `existing Device link found for ${activeRecord.authorityRoot}`); const requestedRouteKey = readOptionalString(options.routeKey); if (normalizeCloudUrl(activeRecord.cloudUrl) !== cloudUrl) { throw new Error( `HIVECAST_LINK_CLOUD_MISMATCH: Host is already linked to ${activeRecord.cloudUrl}; use --relink to replace it`, ); } if (requestedRouteKey && activeRecord.routeKey !== requestedRouteKey) { throw new Error( `HIVECAST_LINK_ROUTE_MISMATCH: Host is already linked to route key ${activeRecord.routeKey ?? '(none)'}; use --relink to replace it`, ); } if (options.device === true) { reportLoginProgress(options, 'link', 'refreshing linked Device credentials'); await hiveCastRefreshCredentialsCommand({ cwd, home: options.home, authorityCoordinator: false, }); const refreshed = readHiveCastHostLink(cwd, options.home); if (refreshed.record) { activeRecord = refreshed.record; activePaths = refreshed.paths; } } const expectation = hostConfigExpectationForLink(activePaths.matrixHome, activeRecord, false); reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`); const hostConfigReload = await reloadRunningHostConfig(activePaths.matrixHome, expectation); assertRunningHostConfigReloadApplied(hostConfigReload, expectation); reportLoginProgress( options, 'namespace', hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload', ); return { mode: 'linked', linked: true, alreadyLinked: true, cloudUrl: activeRecord.cloudUrl, matrixHome: activePaths.matrixHome, linkPath: activePaths.linkPath, hostConfigPath: activePaths.hostConfigPath, natsCredentialsPath: activePaths.natsCredentialsPath, ...(activeRecord.hostLinkId ? { hostLinkId: activeRecord.hostLinkId } : {}), ...(activeRecord.hostId ? { hostId: activeRecord.hostId } : {}), authorityRoot: activeRecord.authorityRoot, ...(activeRecord.routeKey ? { routeKey: activeRecord.routeKey } : {}), ...(activeRecord.publicNamespace ? { publicNamespace: activeRecord.publicNamespace } : {}), ...(activeRecord.spaceId ? { spaceId: activeRecord.spaceId } : {}), ...(typeof activeRecord.authorityCoordinator === 'boolean' ? { authorityCoordinator: activeRecord.authorityCoordinator } : {}), linkedAt: activeRecord.linkedAt, hostConfigReload, }; } let exchange: INormalizedSetupExchange | null = null; if (options.device) { reportLoginProgress(options, 'link', `starting device approval with ${cloudUrl}`); const challenge = await startDeviceFlow(cloudUrl, options); options.onDeviceStart?.(challenge); if (options.waitForCallback === false) { return { mode: 'approval-required', linked: false, cloudUrl, setupUrl: challenge.verificationUriComplete ?? challenge.verificationUri, deviceCode: challenge.deviceCode, userCode: challenge.userCode, verificationUri: challenge.verificationUri, ...(challenge.verificationUriComplete ? { verificationUriComplete: challenge.verificationUriComplete } : {}), expiresIn: challenge.expiresIn, }; } reportLoginProgress(options, 'link', `waiting for approval code ${challenge.userCode}`); await pollDeviceApproval( cloudUrl, challenge, options.pollTimeoutMs ?? options.callbackTimeoutMs ?? deviceChallengeTimeoutMs(challenge), ); reportLoginProgress(options, 'link', 'approved'); reportLoginProgress(options, 'link', 'exchanging device credentials'); exchange = await exchangeDeviceCode(cloudUrl, challenge.deviceCode); } const setupCode = readOptionalString(options.setupCode); if (!exchange && setupCode) { exchange = await exchangeSetupCode(cloudUrl, setupCode); } if (!exchange && options.waitForCallback) { exchange = await waitForLoopbackSetupCode(cloudUrl, options); } if (!exchange) { const pair = await startPairFlow(cloudUrl, options); return { mode: 'approval-required', linked: false, cloudUrl, setupUrl: pair.approvalUrl, }; } const routeKey = readOptionalString(options.routeKey) ?? exchange.routeKey; const publicNamespace = exchange.publicNamespace ?? (routeKey ? `space.${routeKey}` : undefined); const requestedHostName = readOptionalString(options.hostName); const { identity: installIdentity } = ensureHiveCastInstallIdentity({ cwd, matrixHome: options.home, ...(requestedHostName ? { hostName: requestedHostName } : {}), }); const hostName = installIdentity.hostName ?? exchange.hostName ?? requestedHostName; const { record, paths } = saveHiveCastHostLink({ cwd, matrixHome: options.home, cloudUrl, ...(exchange.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot } : {}), authorityRoot: exchange.authorityRoot, nats: exchange.nats, natsCredentials: exchange.credentials, ...(exchange.hostLinkToken ? { hostLinkToken: exchange.hostLinkToken } : {}), ...(exchange.hostLinkId ? { hostLinkId: exchange.hostLinkId } : {}), ...(exchange.hostId ? { hostId: exchange.hostId } : {}), ...(exchange.principalId ? { principalId: exchange.principalId } : {}), ...(hostName ? { hostName } : {}), ...(exchange.deviceSlug ? { deviceSlug: exchange.deviceSlug } : {}), ...(typeof exchange.authorityCoordinator === 'boolean' ? { authorityCoordinator: exchange.authorityCoordinator } : {}), ...(exchange.spaceId ? { spaceId: exchange.spaceId } : {}), ...(routeKey ? { routeKey } : {}), ...(publicNamespace ? { publicNamespace } : {}), }); reportLoginProgress(options, 'link', `writing credentials: ${paths.linkPath}`); const expectation = hostConfigExpectationForLink(paths.matrixHome, record, true); reportLoginProgress(options, 'namespace', `applying authority root ${expectation.root}`); const hostConfigReload = await reloadRunningHostConfig(paths.matrixHome, expectation); assertRunningHostConfigReloadApplied(hostConfigReload, expectation); reportLoginProgress( options, 'namespace', hostConfigReload.applied ? `Host config converged at ${expectation.root}` : 'no running Host config to reload', ); reportLoginProgress(options, 'ready', `Device link persisted for ${record.authorityRoot}`); return { mode: 'linked', linked: true, cloudUrl, matrixHome: paths.matrixHome, linkPath: paths.linkPath, hostConfigPath: paths.hostConfigPath, natsCredentialsPath: paths.natsCredentialsPath, ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), ...(record.hostId ? { hostId: record.hostId } : {}), authorityRoot: record.authorityRoot, ...(record.routeKey ? { routeKey: record.routeKey } : {}), ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), ...(record.spaceId ? { spaceId: record.spaceId } : {}), ...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}), ...(record.hostName ? { hostName: record.hostName } : {}), ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), linkedAt: record.linkedAt, hostConfigReload, }; } async function revokeHiveCastCloudHostLink( existing: ReturnType, ): Promise { const { record, paths } = existing; if (!record) { return { attempted: false, revoked: false, skippedReason: 'not-linked' }; } if (!record.hostLinkId || !record.hostId) { return { attempted: false, revoked: false, skippedReason: 'missing-host-link-id-or-host-id' }; } const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath) ? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim() : ''; if (!hostLinkToken) { return { attempted: false, revoked: false, skippedReason: 'missing-host-link-token' }; } try { const response = await fetch(`${record.cloudUrl}/_auth/host-link/revoke`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${hostLinkToken}`, }, body: JSON.stringify({ hostLinkId: record.hostLinkId, hostId: record.hostId, }), }); const parsed = await readJsonResponse<{ readonly ok?: unknown; readonly error?: unknown }>( response, 'HiveCast Host-link revoke', ); if (!response.ok || parsed.ok !== true) { return { attempted: true, revoked: false, error: readOptionalString(parsed.error) ?? `HiveCast Host-link revoke failed: HTTP ${response.status}`, }; } return { attempted: true, revoked: true }; } catch (error) { return { attempted: true, revoked: false, error: error instanceof Error ? error.message : String(error), }; } } export async function hiveCastRefreshCredentialsCommand( options: IHiveCastRefreshCredentialsCommandOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const { record, paths } = readHiveCastHostLink(cwd, options.home); if (!record) { throw new Error('HIVECAST_REFRESH_NOT_LINKED: no local HiveCast Host link found'); } if (!record.hostLinkId || !record.hostId) { throw new Error('HIVECAST_REFRESH_MISSING_LINK_ID: local HiveCast Host link is missing hostLinkId or hostId'); } const refreshThresholdSeconds = options.ifExpiringWithinSeconds; const currentExpirationSeconds = readCredentialsExpirationSeconds(paths.natsCredentialsPath) ?? readCredentialsExpirationSeconds(paths.hostCredentialsPath); if (refreshThresholdSeconds !== undefined && currentExpirationSeconds !== null) { const currentCredentialsSecondsRemaining = currentExpirationSeconds - Math.floor(Date.now() / 1000); if (currentCredentialsSecondsRemaining > refreshThresholdSeconds) { return { refreshed: false, matrixHome: paths.matrixHome, linkPath: paths.linkPath, natsCredentialsPath: paths.natsCredentialsPath, currentCredentialsExpiresAt: new Date(currentExpirationSeconds * 1000).toISOString(), currentCredentialsSecondsRemaining, refreshThresholdSeconds, ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), ...(record.hostId ? { hostId: record.hostId } : {}), authorityRoot: record.authorityRoot, ...(record.routeKey ? { routeKey: record.routeKey } : {}), ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), ...(record.spaceId ? { spaceId: record.spaceId } : {}), ...(record.hostName ? { hostName: record.hostName } : {}), ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), ...(typeof record.authorityCoordinator === 'boolean' ? { authorityCoordinator: record.authorityCoordinator } : {}), }; } } const hostLinkToken = fs.existsSync(paths.hostLinkTokenPath) ? fs.readFileSync(paths.hostLinkTokenPath, 'utf8').trim() : ''; if (!hostLinkToken) { throw new Error('HIVECAST_REFRESH_MISSING_TOKEN: local HiveCast Host link is missing its refresh token'); } const response = await fetch(`${record.cloudUrl}/_auth/host-link/credentials/refresh`, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${hostLinkToken}`, }, body: JSON.stringify({ hostLinkId: record.hostLinkId, hostId: record.hostId, ...(options.authorityCoordinator === false ? { authorityCoordinator: false } : {}), }), }); const parsed = await readJsonResponse(response, 'HiveCast credential refresh'); if (!response.ok || parsed.ok !== true) { const message = readOptionalString(parsed.error) ?? `HiveCast credential refresh failed: HTTP ${response.status}`; throw new Error(`HIVECAST_REFRESH_FAILED: ${message}`); } const exchange = normalizeSetupExchange(parsed); const routeKey = exchange.routeKey ?? record.routeKey; const publicNamespace = exchange.publicNamespace ?? record.publicNamespace; const hostName = exchange.hostName ?? record.hostName; const { record: saved, paths: savedPaths } = saveHiveCastHostLink({ cwd, matrixHome: options.home, cloudUrl: record.cloudUrl, ...(exchange.deviceRegistryRoot ?? record.deviceRegistryRoot ? { deviceRegistryRoot: exchange.deviceRegistryRoot ?? record.deviceRegistryRoot } : {}), authorityRoot: exchange.authorityRoot, nats: exchange.nats, natsCredentials: exchange.credentials, hostLinkToken: exchange.hostLinkToken ?? hostLinkToken, ...(exchange.hostLinkId ?? record.hostLinkId ? { hostLinkId: exchange.hostLinkId ?? record.hostLinkId } : {}), ...(exchange.hostId ?? record.hostId ? { hostId: exchange.hostId ?? record.hostId } : {}), ...(exchange.principalId ?? record.principalId ? { principalId: exchange.principalId ?? record.principalId } : {}), ...(hostName ? { hostName } : {}), ...(exchange.deviceSlug ?? record.deviceSlug ? { deviceSlug: exchange.deviceSlug ?? record.deviceSlug } : {}), ...(typeof (exchange.authorityCoordinator ?? record.authorityCoordinator) === 'boolean' ? { authorityCoordinator: exchange.authorityCoordinator ?? record.authorityCoordinator } : {}), ...(exchange.spaceId ?? record.spaceId ? { spaceId: exchange.spaceId ?? record.spaceId } : {}), ...(routeKey ? { routeKey } : {}), ...(publicNamespace ? { publicNamespace } : {}), linkedAt: record.linkedAt, }); const hostConfigReload = await reloadRunningHostConfig( savedPaths.matrixHome, hostConfigExpectationForLink(savedPaths.matrixHome, saved, true), ); assertRunningHostConfigReloadApplied( hostConfigReload, hostConfigExpectationForLink(savedPaths.matrixHome, saved, true), ); const refreshedExpirationSeconds = readCredentialsExpirationSeconds(savedPaths.natsCredentialsPath); return { refreshed: true, matrixHome: savedPaths.matrixHome, linkPath: savedPaths.linkPath, natsCredentialsPath: savedPaths.natsCredentialsPath, ...(refreshedExpirationSeconds !== null ? { currentCredentialsExpiresAt: new Date(refreshedExpirationSeconds * 1000).toISOString(), currentCredentialsSecondsRemaining: refreshedExpirationSeconds - Math.floor(Date.now() / 1000), } : {}), ...(refreshThresholdSeconds !== undefined ? { refreshThresholdSeconds } : {}), ...(saved.hostLinkId ? { hostLinkId: saved.hostLinkId } : {}), ...(saved.hostId ? { hostId: saved.hostId } : {}), authorityRoot: saved.authorityRoot, ...(saved.routeKey ? { routeKey: saved.routeKey } : {}), ...(saved.publicNamespace ? { publicNamespace: saved.publicNamespace } : {}), ...(saved.spaceId ? { spaceId: saved.spaceId } : {}), ...(saved.hostName ? { hostName: saved.hostName } : {}), ...(saved.deviceSlug ? { deviceSlug: saved.deviceSlug } : {}), ...(typeof saved.authorityCoordinator === 'boolean' ? { authorityCoordinator: saved.authorityCoordinator } : {}), hostConfigReload, }; } export async function hiveCastLogoutCommand( options: IHiveCastLogoutCommandOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const existing = readHiveCastHostLink(cwd, options.home); const shouldRevokeCloudLink = options.all === true || options.revokeCloudLink === true; const cloudRevoke = shouldRevokeCloudLink ? await revokeHiveCastCloudHostLink(existing) : undefined; const result = removeHiveCastHostLink(cwd, options.home, { preserveCredentials: true }); await ensureLocalOwnerNatsDependency(result.paths.matrixHome); const localOwnerTransport = localOwnerTransportForExpectation(result.paths.matrixHome); if (!localOwnerTransport) { throw new Error(`HIVECAST_LINK_LOCAL_TRANSPORT_MISSING: installed local Host transport is missing for ${result.paths.matrixHome}`); } const hostConfigReload = await reloadRunningHostConfig(result.paths.matrixHome, { root: localOwnerTransport.root, authorityRoot: localOwnerTransport.authorityRoot ?? localOwnerTransport.root, routeKey: null, publicNamespace: null, spaceId: null, forceReload: true, label: 'local-owner', }); removeHiveCastCredentialFiles(result.paths); return { removed: result.removed, ...(cloudRevoke ? { cloudRevoke } : {}), hostConfigReset: result.hostConfigReset, matrixHome: result.paths.matrixHome, linkPath: result.paths.linkPath, natsCredentialsPath: result.paths.natsCredentialsPath, hostConfigReload, }; } export async function hiveCastWhoamiCommand( options: IAuthContextOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); const { record, paths } = readHiveCastHostLink(cwd, options.home); if (!record) { return { linked: false, matrixHome: paths.matrixHome, }; } return { linked: true, matrixHome: paths.matrixHome, cloudUrl: record.cloudUrl, ...(record.hostLinkId ? { hostLinkId: record.hostLinkId } : {}), authorityRoot: record.authorityRoot, ...(record.routeKey ? { routeKey: record.routeKey } : {}), ...(record.publicNamespace ? { publicNamespace: record.publicNamespace } : {}), ...(record.spaceId ? { spaceId: record.spaceId } : {}), ...(record.principalId ? { principalId: record.principalId } : {}), ...(record.hostName ? { hostName: record.hostName } : {}), ...(record.deviceSlug ? { deviceSlug: record.deviceSlug } : {}), linkedAt: record.linkedAt, }; } export async function hiveCastLinkStatusCommand( options: IAuthContextOptions = {}, ): Promise { const cwd = options.cwd ?? process.cwd(); return readHiveCastLinkStatus(cwd, options.home); } export function formatHiveCastWhoami(result: IHiveCastWhoamiCommandResult): string { if (!result.linked) { return `HiveCast Host is not linked (${result.matrixHome})`; } return [ `Cloud: ${result.cloudUrl ?? 'unknown'}`, result.hostName ? `Device name: ${result.hostName}` : null, result.deviceSlug ? `Device inventory projection: system.devices.${result.deviceSlug}` : null, result.routeKey ? `Route key: ${result.routeKey}` : null, result.publicNamespace ? `Public namespace: ${result.publicNamespace}` : null, result.spaceId ? `Space: ${result.spaceId}` : null, `Authority root: ${result.authorityRoot ?? 'unknown'}`, `Matrix home: ${result.matrixHome}`, ].filter((line): line is string => Boolean(line)).join('\n'); }