diff --git a/docs/agent-handoffs/learnings/2026-02-14-secure-dashboard-binding.md b/docs/agent-handoffs/learnings/2026-02-14-secure-dashboard-binding.md new file mode 100644 index 0000000..3ef6d4f --- /dev/null +++ b/docs/agent-handoffs/learnings/2026-02-14-secure-dashboard-binding.md @@ -0,0 +1,7 @@ +# Secure Dashboard Binding + +- **Context:** Hardening the local dashboard server. +- **Observation:** Default `server.listen(port)` in Node.js binds to all interfaces (`0.0.0.0`), exposing the tool to the local network. +- **Action taken:** Explicitly added `'127.0.0.1'` as the hostname argument to `server.listen`. +- **Validation performed:** Verified code change with `grep`. +- **Recommendation for next agents:** Always specify `'127.0.0.1'` for servers intended only for local use. diff --git a/src/lib.mjs b/src/lib.mjs index 396a28e..0423407 100644 --- a/src/lib.mjs +++ b/src/lib.mjs @@ -19,11 +19,545 @@ import { ExitError } from './errors.mjs'; useWebSocketImplementation(WebSocket); -// Re-export for backward compatibility/library usage -export { parseLockEvent, queryLocks, publishLock, cmdDashboard }; -export { cmdCheck } from './cmd-check.mjs'; -export { cmdLock } from './cmd-lock.mjs'; -export { cmdList } from './cmd-list.mjs'; +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.primal.net', +]; + +const DEFAULT_TTL = 7200; +const DEFAULT_NAMESPACE = 'torch'; +const DEFAULT_QUERY_TIMEOUT_MS = 15_000; +const VALID_CADENCES = new Set(['daily', 'weekly']); + +// If installed as a package, prompts/roster.json is relative to this file +const ROSTER_FILE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'prompts/roster.json'); +const USER_ROSTER_FILE = path.resolve(process.cwd(), 'torch/roster.json'); + +const FALLBACK_ROSTER = { + daily: [ + 'audit-agent', + 'ci-health-agent', + 'const-refactor-agent', + 'content-audit-agent', + 'decompose-agent', + 'deps-security-agent', + 'design-system-audit-agent', + 'docs-agent', + 'docs-alignment-agent', + 'docs-code-investigator', + 'innerhtml-migration-agent', + 'known-issues-agent', + 'load-test-agent', + 'onboarding-audit-agent', + 'perf-agent', + 'prompt-curator-agent', + 'protocol-research-agent', + 'scheduler-update-agent', + 'style-agent', + 'test-audit-agent', + 'todo-triage-agent', + ], + weekly: [ + 'bug-reproducer-agent', + 'changelog-agent', + 'dead-code-agent', + 'event-schema-agent', + 'frontend-console-debug-agent', + 'fuzz-agent', + 'interop-agent', + 'perf-deepdive-agent', + 'perf-optimization-agent', + 'pr-review-agent', + 'race-condition-agent', + 'refactor-agent', + 'smoke-agent', + 'telemetry-agent', + 'test-coverage-agent', + 'weekly-synthesis-agent', + ], +}; + +let cachedCanonicalRoster = null; + +// Custom Error class for controlled exits +class ExitError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +function getRelays() { + const config = loadTorchConfig(); + const envRelays = process.env.NOSTR_LOCK_RELAYS; + if (envRelays) { + return envRelays.split(',').map((r) => r.trim()).filter(Boolean); + } + if (config.nostrLock.relays?.length) { + return config.nostrLock.relays; + } + return DEFAULT_RELAYS; +} + +function getNamespace() { + const config = loadTorchConfig(); + const namespace = (process.env.NOSTR_LOCK_NAMESPACE || config.nostrLock.namespace || DEFAULT_NAMESPACE).trim(); + return namespace || DEFAULT_NAMESPACE; +} + +function getTtl() { + const config = loadTorchConfig(); + const envTtl = process.env.NOSTR_LOCK_TTL; + if (envTtl) { + const parsed = parseInt(envTtl, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + if (config.nostrLock.ttlSeconds) { + return config.nostrLock.ttlSeconds; + } + return DEFAULT_TTL; +} + +function getQueryTimeoutMs() { + const config = loadTorchConfig(); + const envValue = process.env.NOSTR_LOCK_QUERY_TIMEOUT_MS; + if (envValue) { + const parsed = parseInt(envValue, 10); + if (!Number.isNaN(parsed) && parsed > 0) return parsed; + } + return config.nostrLock.queryTimeoutMs || DEFAULT_QUERY_TIMEOUT_MS; +} + + +function loadCanonicalRoster() { + if (cachedCanonicalRoster) return cachedCanonicalRoster; + + let rosterPath = ROSTER_FILE; + + // Prefer user-managed roster if present + if (fs.existsSync(USER_ROSTER_FILE)) { + rosterPath = USER_ROSTER_FILE; + } + + try { + const parsed = JSON.parse(fs.readFileSync(rosterPath, 'utf8')); + const daily = Array.isArray(parsed.daily) ? parsed.daily.map((item) => String(item).trim()).filter(Boolean) : []; + const weekly = Array.isArray(parsed.weekly) + ? parsed.weekly.map((item) => String(item).trim()).filter(Boolean) + : []; + + if (daily.length > 0 && weekly.length > 0) { + cachedCanonicalRoster = { daily, weekly }; + return cachedCanonicalRoster; + } + + console.error(`WARNING: Roster file is missing daily/weekly entries, falling back: ${rosterPath}`); + } catch { + // It's okay if roster file is missing when used as a library/CLI without the file present + } + + cachedCanonicalRoster = FALLBACK_ROSTER; + return cachedCanonicalRoster; +} + +function parseEnvRoster(value) { + if (!value) return null; + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function getRoster(cadence) { + const config = loadTorchConfig(); + const dailyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_DAILY_ROSTER); + const weeklyFromEnv = parseEnvRoster(process.env.NOSTR_LOCK_WEEKLY_ROSTER); + const canonical = loadCanonicalRoster(); + const dailyFromConfig = config.nostrLock.dailyRoster; + const weeklyFromConfig = config.nostrLock.weeklyRoster; + + if (cadence === 'daily') { + if (dailyFromEnv && dailyFromEnv.length) return dailyFromEnv; + if (dailyFromConfig && dailyFromConfig.length) return dailyFromConfig; + return canonical.daily; + } + + if (weeklyFromEnv && weeklyFromEnv.length) return weeklyFromEnv; + if (weeklyFromConfig && weeklyFromConfig.length) return weeklyFromConfig; + return canonical.weekly; +} + +function todayDateStr() { + return new Date().toISOString().slice(0, 10); +} + +function nowUnix() { + return Math.floor(Date.now() / 1000); +} + +export function parseLockEvent(event) { + const dTag = event.tags.find((t) => t[0] === 'd')?.[1] ?? ''; + const expTag = event.tags.find((t) => t[0] === 'expiration')?.[1]; + const expiresAt = expTag ? parseInt(expTag, 10) : null; + + let content = {}; + try { + const parsed = JSON.parse(event.content); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + content = parsed; + } + } catch { + // Ignore malformed JSON content + } + + return { + eventId: event.id, + pubkey: event.pubkey, + createdAt: event.created_at, + createdAtIso: new Date(event.created_at * 1000).toISOString(), + expiresAt, + expiresAtIso: expiresAt ? new Date(expiresAt * 1000).toISOString() : null, + dTag, + agent: content.agent ?? null, + cadence: content.cadence ?? null, + status: content.status ?? null, + date: content.date ?? null, + platform: content.platform ?? null, + }; +} + +function filterActiveLocks(locks) { + const now = nowUnix(); + return locks.filter((lock) => !lock.expiresAt || lock.expiresAt > now); +} + +export async function queryLocks(relays, cadence, dateStr, namespace) { + const pool = new SimplePool(); + const tagFilter = `${namespace}-lock-${cadence}-${dateStr}`; + const queryTimeoutMs = getQueryTimeoutMs(); + + try { + const events = await Promise.race([ + pool.querySync(relays, { + kinds: [30078], + '#t': [tagFilter], + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Relay query timed out')), queryTimeoutMs), + ), + ]); + + return filterActiveLocks(events.map(parseLockEvent)); + } finally { + pool.close(relays); + } +} + +export async function publishLock(relays, event) { + const pool = new SimplePool(); + + try { + const publishPromises = pool.publish(relays, event); + const results = await Promise.allSettled(publishPromises); + const successes = results.filter((r) => r.status === 'fulfilled'); + + if (successes.length === 0) { + const errors = results.map((r, i) => { + if (r.status === 'rejected') { + const reason = r.reason; + const message = reason instanceof Error ? reason.message : String(reason ?? 'unknown'); + return `${relays[i]}: ${message}`; + } + return `${relays[i]}: unknown`; + }); + throw new Error(`Failed to publish to any relay:\n ${errors.join('\n ')}`); + } + + console.error(` Published to ${successes.length}/${relays.length} relays`); + return event; + } finally { + pool.close(relays); + } +} + +export async function cmdCheck(cadence) { + const relays = getRelays(); + const namespace = getNamespace(); + const dateStr = todayDateStr(); + const config = loadTorchConfig(); + const pausedAgents = (cadence === 'daily' ? config.scheduler.paused.daily : config.scheduler.paused.weekly) || []; + + console.error(`Checking locks: namespace=${namespace}, cadence=${cadence}, date=${dateStr}`); + console.error(`Relays: ${relays.join(', ')}`); + if (pausedAgents.length > 0) { + console.error(`Paused agents: ${pausedAgents.join(', ')}`); + } + + const locks = await queryLocks(relays, cadence, dateStr, namespace); + const lockedAgents = [...new Set(locks.map((l) => l.agent).filter(Boolean))]; + const roster = getRoster(cadence); + + const excludedAgents = [...new Set([...lockedAgents, ...pausedAgents])]; + const unknownLockedAgents = lockedAgents.filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !excludedAgents.includes(a)); + + const result = { + namespace, + cadence, + date: dateStr, + locked: lockedAgents.sort(), + paused: pausedAgents.sort(), + excluded: excludedAgents.sort(), + available: available.sort(), + lockCount: locks.length, + unknownLockedAgents: unknownLockedAgents.sort(), + locks: locks.map((l) => ({ + agent: l.agent, + eventId: l.eventId, + createdAt: l.createdAtIso, + expiresAt: l.expiresAtIso, + platform: l.platform, + })), + }; + + console.log(JSON.stringify(result, null, 2)); + return result; +} + +export async function cmdLock(agent, cadence, dryRun = false) { + const relays = getRelays(); + const namespace = getNamespace(); + const dateStr = todayDateStr(); + const ttl = getTtl(); + const now = nowUnix(); + const expiresAt = now + ttl; + + console.error(`Locking: namespace=${namespace}, agent=${agent}, cadence=${cadence}, date=${dateStr}`); + console.error(`TTL: ${ttl}s, expires: ${new Date(expiresAt * 1000).toISOString()}`); + console.error(`Relays: ${relays.join(', ')}`); + + const roster = getRoster(cadence); + if (!roster.includes(agent)) { + console.error(`ERROR: agent "${agent}" is not in the ${cadence} roster`); + console.error(`Allowed ${cadence} agents: ${roster.join(', ')}`); + throw new ExitError(1, 'Agent not in roster'); + } + + console.error('Step 1: Checking for existing locks...'); + const existingLocks = await queryLocks(relays, cadence, dateStr, namespace); + const conflicting = existingLocks.filter((l) => l.agent === agent); + + if (conflicting.length > 0) { + const earliest = conflicting.sort((a, b) => a.createdAt - b.createdAt)[0]; + console.error( + `LOCK DENIED: ${agent} already locked by event ${earliest.eventId} ` + + `(created ${earliest.createdAtIso}, platform: ${earliest.platform})`, + ); + console.log('LOCK_STATUS=denied'); + console.log('LOCK_REASON=already_locked'); + console.log(`LOCK_EXISTING_EVENT=${earliest.eventId}`); + throw new ExitError(3, 'Lock denied'); + } + + console.error('Step 2: Generating ephemeral keypair...'); + const sk = generateSecretKey(); + const pk = getPublicKey(sk); + console.error(` Ephemeral pubkey: ${pk.slice(0, 16)}...`); + + console.error('Step 3: Building lock event...'); + const event = finalizeEvent( + { + kind: 30078, + created_at: now, + tags: [ + ['d', `${namespace}-lock/${cadence}/${agent}/${dateStr}`], + ['t', `${namespace}-agent-lock`], + ['t', `${namespace}-lock-${cadence}`], + ['t', `${namespace}-lock-${cadence}-${dateStr}`], + ['expiration', String(expiresAt)], + ], + content: JSON.stringify({ + agent, + cadence, + status: 'started', + namespace, + date: dateStr, + platform: process.env.AGENT_PLATFORM || 'unknown', + lockedAt: new Date(now * 1000).toISOString(), + expiresAt: new Date(expiresAt * 1000).toISOString(), + }), + }, + sk, + ); + + console.error(` Event ID: ${event.id}`); + + if (dryRun) { + console.error('Step 4: [DRY RUN] Skipping publish — event built but not sent'); + console.error('RACE CHECK: won (dry run — no real contention possible)'); + } else { + console.error('Step 4: Publishing to relays...'); + await publishLock(relays, event); + + console.error('Step 5: Race check...'); + await new Promise((resolve) => setTimeout(resolve, RACE_CHECK_DELAY_MS)); + + const postLocks = await queryLocks(relays, cadence, dateStr, namespace); + const racingLocks = postLocks + .filter((l) => l.agent === agent) + .sort((a, b) => a.createdAt - b.createdAt); + + if (racingLocks.length > 1 && racingLocks[0].eventId !== event.id) { + const winner = racingLocks[0]; + console.error( + `RACE CHECK: lost (earlier lock by event ${winner.eventId}, created ${winner.createdAtIso})`, + ); + console.log('LOCK_STATUS=race_lost'); + console.log('LOCK_REASON=earlier_claim_exists'); + console.log(`LOCK_WINNER_EVENT=${winner.eventId}`); + throw new ExitError(3, 'Race check lost'); + } + + console.error('RACE CHECK: won'); + } + + console.log('LOCK_STATUS=ok'); + console.log(`LOCK_EVENT_ID=${event.id}`); + console.log(`LOCK_PUBKEY=${pk}`); + console.log(`LOCK_AGENT=${agent}`); + console.log(`LOCK_CADENCE=${cadence}`); + console.log(`LOCK_DATE=${dateStr}`); + console.log(`LOCK_EXPIRES=${expiresAt}`); + console.log(`LOCK_EXPIRES_ISO=${new Date(expiresAt * 1000).toISOString()}`); + return { status: 'ok', eventId: event.id }; +} + +export async function cmdList(cadence) { + const relays = getRelays(); + const namespace = getNamespace(); + const dateStr = todayDateStr(); + const cadences = cadence ? [cadence] : ['daily', 'weekly']; + + console.error(`Listing active locks: namespace=${namespace}, cadences=${cadences.join(', ')}`); + + for (const c of cadences) { + const locks = await queryLocks(relays, c, dateStr, namespace); + + console.log(`\n${'='.repeat(72)}`); + console.log(`Active ${namespace} ${c} locks (${dateStr})`); + console.log('='.repeat(72)); + + if (locks.length === 0) { + console.log(' (no active locks)'); + continue; + } + + const sorted = locks.sort((a, b) => a.createdAt - b.createdAt); + for (const lock of sorted) { + const age = nowUnix() - lock.createdAt; + const ageMin = Math.round(age / 60); + const remaining = lock.expiresAt ? lock.expiresAt - nowUnix() : null; + const remainMin = remaining ? Math.round(remaining / 60) : '?'; + + console.log( + ` ${(lock.agent ?? 'unknown').padEnd(30)} ` + + `age: ${String(ageMin).padStart(4)}m ` + + `ttl: ${String(remainMin).padStart(4)}m ` + + `platform: ${lock.platform ?? '?'} ` + + `event: ${lock.eventId?.slice(0, 12)}...`, + ); + } + + const roster = getRoster(c); + const lockedAgents = new Set(locks.map((l) => l.agent).filter(Boolean)); + const unknownLockedAgents = [...lockedAgents].filter((agent) => !roster.includes(agent)); + const available = roster.filter((a) => !lockedAgents.has(a)); + + if (unknownLockedAgents.length > 0) { + console.log(` Warning: lock events found with non-roster agent names: ${unknownLockedAgents.join(', ')}`); + } + + console.log(`\n Locked: ${lockedAgents.size}/${roster.length}`); + console.log(` Available: ${available.join(', ') || '(none)'}`); + } +} + +export async function cmdDashboard(port = DEFAULT_DASHBOARD_PORT) { + // Resolve package root relative to this file (src/lib.mjs) + // this file is in /src/lib.mjs, so '..' goes to + const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + + const server = http.createServer((req, res) => { + // URL parsing + const url = new URL(req.url, `http://${req.headers.host}`); + let pathname = url.pathname; + + // Redirect / to /dashboard/ + if (pathname === '/' || pathname === '/dashboard') { + res.writeHead(302, { 'Location': '/dashboard/' }); + res.end(); + return; + } + + // Special case: /torch-config.json + // Priority: User's CWD > Package default + if (pathname === '/torch-config.json') { + const userConfigPath = path.resolve(process.cwd(), 'torch-config.json'); + if (fs.existsSync(userConfigPath)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + fs.createReadStream(userConfigPath).pipe(res); + return; + } + // If not found in CWD, fall through to serve from packageRoot (if it exists there) + // or return empty object if missing? + // Falling through means it looks for packageRoot/torch-config.json + } + + // Security check: prevent directory traversal + const safePath = path.normalize(pathname).replace(new RegExp('^(\\.\\.[\\/\\\\])+'), ''); + let filePath = path.join(packageRoot, safePath); + + // If directory, try index.html + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, 'index.html'); + } + + // Check if file exists and is a file + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + res.writeHead(404); + res.end('Not Found'); + return; + } + + // MIME types + const extname = path.extname(filePath); + let contentType = 'text/plain'; + switch (extname) { + case '.html': contentType = 'text/html'; break; + case '.js': contentType = 'text/javascript'; break; + case '.css': contentType = 'text/css'; break; + case '.json': contentType = 'application/json'; break; + case '.png': contentType = 'image/png'; break; + case '.jpg': contentType = 'image/jpeg'; break; + case '.svg': contentType = 'image/svg+xml'; break; + case '.ico': contentType = 'image/x-icon'; break; + case '.md': contentType = 'text/markdown'; break; + } + + res.writeHead(200, { 'Content-Type': contentType }); + fs.createReadStream(filePath).pipe(res); + }); + + server.listen(port, '127.0.0.1', () => { + console.log(`Dashboard running at http://localhost:${port}/dashboard/`); + console.log(`Serving files from ${packageRoot}`); + console.log(`Using configuration from ${process.cwd()}`); + }); + + // Keep process alive + return new Promise(() => {}); +} function parseArgs(argv) { const args = { command: null, agent: null, cadence: null, dryRun: false, force: false, port: DEFAULT_DASHBOARD_PORT };