From f09f9f9993f355d541230be639e47bb52f6b0838 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 7 Jan 2026 10:59:40 -0600 Subject: [PATCH 01/69] first round performance review improvements for headless mode --- src/const.ts | 23 ++ src/node/manager.ts | 138 +++++++++- src/server.ts | 18 +- tests/routes/relay-probe.spec.ts | 334 +++++++++++++++++++++++++ tests/routes/static-imports.spec.ts | 373 ++++++++++++++++++++++++++++ 5 files changed, 868 insertions(+), 18 deletions(-) create mode 100644 tests/routes/relay-probe.spec.ts create mode 100644 tests/routes/static-imports.spec.ts diff --git a/src/const.ts b/src/const.ts index 7928faa..122440c 100644 --- a/src/const.ts +++ b/src/const.ts @@ -89,3 +89,26 @@ export const SKIP_ADMIN_SECRET_VALIDATION = (() => { const normalized = trimmed.toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'yes'; })(); + +// Skip relay probing during node creation for faster startup (perf optimization 3.1) +// When true, uses all configured relays without testing kind 20004 support +export const SKIP_RELAY_PROBE = (() => { + const value = process.env['SKIP_RELAY_PROBE']; + if (!value) return false; + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + const normalized = trimmed.toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; +})(); + +// Defer relay probing to background for faster startup (perf optimization 3.1) +// When true, node starts with all relays; probe runs in background for diagnostics only +// SKIP_RELAY_PROBE takes precedence over this setting +export const DEFER_RELAY_PROBE = (() => { + const value = process.env['DEFER_RELAY_PROBE']; + if (!value) return false; + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + const normalized = trimmed.toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; +})(); diff --git a/src/node/manager.ts b/src/node/manager.ts index b6b97b3..8de58cf 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -15,6 +15,7 @@ import type { NodePolicyInput, NodeEventConfig, EnhancedNodeConfig } from '@fros import { randomBytes } from 'crypto'; import type { ServerBifrostNode, PeerStatus, PingResult } from '../routes/types.js'; import { getValidRelays, safeStringify, getOpTimeoutMs } from '../routes/utils.js'; +import { SKIP_RELAY_PROBE, DEFER_RELAY_PROBE } from '../const.js'; import { loadFallbackPeerPolicies } from './peer-policy-store.js'; import { mergePolicyInputs } from '../util/peer-policy.js'; import type { ServerWebSocket } from 'bun'; @@ -642,6 +643,17 @@ const MAX_RECREATION_ATTEMPTS = 5; let nextRecreationBackoffMs = 60000; // Start with 1 minute backoff let nextRecreationAllowedAt = 0; // Timestamp when next recreation is allowed +// Background relay probe state (perf optimization 3.1) +let backgroundProbePromise: Promise | null = null; +let backgroundProbeGeneration = 0; + +interface BackgroundProbeResult { + originalRelays: string[]; + filteredRelays: string[]; + timestamp: number; +} +let lastBackgroundProbeResult: BackgroundProbeResult | null = null; + // Quick relay capability probe: keep relays that accept the given kind. // Uses an ephemeral keypair and a tiny, throwaway event, and closes connections immediately. export async function filterRelaysForKindSupport( @@ -685,6 +697,81 @@ export async function filterRelaysForKindSupport( } } +/** + * Runs relay probe in background and logs results. + * Does not block startup. (perf optimization 3.1) + */ +async function runBackgroundRelayProbe( + relays: string[], + kind: number = 20004, + addServerLog?: ReturnType +): Promise { + if (relays.length === 0) { + return; + } + + const myGeneration = ++backgroundProbeGeneration; + + try { + if (addServerLog) { + addServerLog('info', 'Starting background relay probe', { relayCount: relays.length }); + } + + const filtered = await filterRelaysForKindSupport(relays, kind, addServerLog); + + // Store result for potential future use + lastBackgroundProbeResult = { + originalRelays: relays, + filteredRelays: filtered, + timestamp: Date.now() + }; + + if (filtered.length === 0) { + if (addServerLog) { + addServerLog('warning', 'Background probe: all relays reject kind 20004; keeping original relay list'); + } + return; + } + + if (filtered.length < relays.length) { + const dropped = relays.filter(r => !filtered.includes(r)); + if (addServerLog) { + addServerLog('info', `Background probe complete: filtered ${dropped.length} relay(s)`, { + dropped, + kept: filtered + }); + } + } else if (addServerLog) { + addServerLog('debug', 'Background probe complete: all relays support kind 20004'); + } + } catch (error) { + if (addServerLog) { + addServerLog('warning', 'Background relay probe failed', { + error: error instanceof Error ? error.message : String(error) + }); + } + } finally { + if (backgroundProbeGeneration === myGeneration) { + backgroundProbePromise = null; + } + } +} + +/** + * Get the last background probe result if available. + */ +export function getLastBackgroundProbeResult(): BackgroundProbeResult | null { + return lastBackgroundProbeResult; +} + +/** + * Cancel any running background probe by clearing the promise reference. + * Note: This does not abort in-flight relay checks; they will complete naturally. + */ +export function cancelBackgroundProbe(): void { + backgroundProbePromise = null; +} + // Helper function to update node activity function updateNodeActivity(addServerLog: ReturnType, isKeepalive: boolean = false) { const now = new Date(); @@ -1975,18 +2062,33 @@ export async function createNodeWithCredentials( } } - // Minimal startup self-test: drop relays that reject kind 20004 (Bifrost) - try { - const tested = await filterRelaysForKindSupport(relays, 20004, addServerLog); - if (tested.length === 0) { - if (addServerLog) addServerLog('warning', 'All configured relays reject kind 20004; proceeding with original list but server may log policy rejections'); - } else if (tested.length < relays.length) { - const dropped = relays.filter(r => !tested.includes(r)); - if (addServerLog) addServerLog('info', `Filtering ${dropped.length} relay(s) that reject kind 20004`, { dropped, kept: tested }); - relays = tested; + // Relay probe configuration via environment variables (perf optimization 3.1) + // Capture relays for potential background probing + const relaysToProbe = [...relays]; + + if (SKIP_RELAY_PROBE) { + if (addServerLog) { + addServerLog('info', 'SKIP_RELAY_PROBE enabled: skipping relay kind 20004 verification'); + } + } else if (DEFER_RELAY_PROBE) { + if (addServerLog) { + addServerLog('info', 'DEFER_RELAY_PROBE enabled: relay verification will run in background'); + } + // Background probe will be started after node creation (see below) + } else { + // Minimal startup self-test: drop relays that reject kind 20004 (Bifrost) + try { + const tested = await filterRelaysForKindSupport(relays, 20004, addServerLog); + if (tested.length === 0) { + if (addServerLog) addServerLog('warning', 'All configured relays reject kind 20004; proceeding with original list but server may log policy rejections'); + } else if (tested.length < relays.length) { + const dropped = relays.filter(r => !tested.includes(r)); + if (addServerLog) addServerLog('info', `Filtering ${dropped.length} relay(s) that reject kind 20004`, { dropped, kept: tested }); + relays = tested; + } + } catch (e) { + if (addServerLog) addServerLog('warning', 'Relay self-test failed; using configured relays as-is', e); } - } catch (e) { - if (addServerLog) addServerLog('warning', 'Relay self-test failed; using configured relays as-is', e); } if (addServerLog) { @@ -2155,7 +2257,12 @@ export async function createNodeWithCredentials( // Don't fail node creation - let monitoring handle it } } - + + // Start background probe if deferred (perf optimization 3.1) + if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { + backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + } + return wrappedNode; } else { throw new Error('Enhanced node creation returned no node'); @@ -2185,6 +2292,12 @@ export async function createNodeWithCredentials( addServerLog('info', 'Node connected and ready (basic mode)'); } const wrappedNode = createInstrumentedNode(node, addServerLog); + + // Start background probe if deferred (perf optimization 3.1) + if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { + backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + } + return wrappedNode; } } catch (basicError) { @@ -2252,6 +2365,7 @@ export function getPublishMetrics() { // Export cleanup function export function cleanupMonitoring() { stopConnectivityMonitoring(); + cancelBackgroundProbe(); } // Reset monitoring state completely (for manual restarts) diff --git a/src/server.ts b/src/server.ts index 3a78d2b..b3842e0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -25,6 +25,14 @@ import { } from './node/manager.js'; import { initNip46Service, getNip46Service } from './nip46/index.js' import { clearCleanupTimers } from './routes/node-manager.js'; +// Static imports for auth and rate-limiter (previously dynamic for perf optimization 1.1) +import { + authenticate, + AUTH_CONFIG, + checkRateLimit, + stopAuthCleanup +} from './routes/auth.js'; +import { cleanupRateLimiter } from './utils/rate-limiter.js'; // Node restart configuration with validation const parseRestartConfig = () => { @@ -731,7 +739,7 @@ const server = serve({ // Handle WebSocket upgrade for event stream if (url.pathname === '/api/events' && req.headers.get('upgrade') === 'websocket') { // WebSocket upgrade rate limit and Origin check - const { authenticate, AUTH_CONFIG, checkRateLimit } = await import('./routes/auth.js'); + // (auth functions now available via static import at top of file) // Sanitize ws-upgrade rate limiter envs const wsWinSecRaw = process.env.RATE_LIMIT_WS_UPGRADE_WINDOW ?? process.env.RATE_LIMIT_WINDOW ?? '900'; @@ -840,7 +848,7 @@ const server = serve({ // Handle WebSocket upgrade for Nostr relay if (url.pathname === '/' && req.headers.get('upgrade') === 'websocket') { // Origin and per-IP protections for relay WS - const { checkRateLimit } = await import('./routes/auth.js'); + // (checkRateLimit now available via static import at top of file) const wsWinSecRaw2 = process.env.RATE_LIMIT_WS_UPGRADE_WINDOW ?? process.env.RATE_LIMIT_WINDOW ?? '900'; const wsWinSecParsed2 = Number.parseInt(wsWinSecRaw2, 10); const wsUpWindow = Math.max(1000, (Number.isFinite(wsWinSecParsed2) ? wsWinSecParsed2 : 900) * 1000); @@ -995,18 +1003,16 @@ async function handleShutdown(signal: string): Promise { cleanupMonitoring(); clearCleanupTimers(); - // Clean up rate limiter + // Clean up rate limiter (cleanupRateLimiter now available via static import) try { - const { cleanupRateLimiter } = await import('./utils/rate-limiter.js'); cleanupRateLimiter(); addServerLog('system', 'Rate limiter cleaned up'); } catch (err) { addServerLog('error', 'Error cleaning up rate limiter', err); } - // Clean up auth timers and vault + // Clean up auth timers and vault (stopAuthCleanup now available via static import) try { - const { stopAuthCleanup } = await import('./routes/auth.js'); stopAuthCleanup(); addServerLog('system', 'Auth cleanup completed'); } catch (err) { diff --git a/tests/routes/relay-probe.spec.ts b/tests/routes/relay-probe.spec.ts new file mode 100644 index 0000000..0271b54 --- /dev/null +++ b/tests/routes/relay-probe.spec.ts @@ -0,0 +1,334 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { pathToFileURL } from 'url'; +import { runRouteScript } from './helpers/script-runner'; + +const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; + +afterEach(() => { + delete process.env.NODE_ENV; + delete process.env.HEADLESS; + delete process.env.SKIP_RELAY_PROBE; + delete process.env.DEFER_RELAY_PROBE; +}); + +describe('Relay probe optimization (3.1)', () => { + describe('SKIP_RELAY_PROBE environment variable', () => { + test('when true, sets constant correctly', () => { + const script = ` + process.env.SKIP_RELAY_PROBE = 'true'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + // Clear module cache to ensure fresh import with new env + const CONST = await import(root + 'src/const.ts?skip1'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipRelayProbeValue: CONST.SKIP_RELAY_PROBE + })); + process.exit(0); + `; + + const result = runRouteScript(script, { SKIP_RELAY_PROBE: 'true' }); + expect(result.skipRelayProbeValue).toBe(true); + }, { timeout: 10000 }); + + test('when false or unset, does not skip probing', () => { + const script = ` + process.env.SKIP_RELAY_PROBE = ''; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + const CONST = await import(root + 'src/const.ts?skip2'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipRelayProbeValue: CONST.SKIP_RELAY_PROBE + })); + process.exit(0); + `; + + const result = runRouteScript(script, { SKIP_RELAY_PROBE: '' }); + expect(result.skipRelayProbeValue).toBe(false); + }, { timeout: 10000 }); + + test('accepts various truthy values', () => { + const truthyValues = ['true', 'TRUE', '1', 'yes', 'YES']; + + for (const value of truthyValues) { + const script = ` + process.env.SKIP_RELAY_PROBE = '${value}'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + const CONST = await import(root + 'src/const.ts?truthy_${value}'); + console.log('@@RESULT@@' + JSON.stringify({ value: '${value}', parsed: CONST.SKIP_RELAY_PROBE })); + process.exit(0); + `; + const result = runRouteScript(script, { SKIP_RELAY_PROBE: value }); + expect(result.parsed).toBe(true); + } + }, { timeout: 15000 }); + + test('rejects invalid values', () => { + const invalidValues = ['false', 'FALSE', '0', 'no', 'NO', 'invalid', '']; + + for (const value of invalidValues) { + const script = ` + process.env.SKIP_RELAY_PROBE = '${value}'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + const CONST = await import(root + 'src/const.ts?invalid_${value}'); + console.log('@@RESULT@@' + JSON.stringify({ value: '${value}', parsed: CONST.SKIP_RELAY_PROBE })); + process.exit(0); + `; + const result = runRouteScript(script, { SKIP_RELAY_PROBE: value }); + expect(result.parsed).toBe(false); + } + }, { timeout: 15000 }); + }); + + describe('DEFER_RELAY_PROBE environment variable', () => { + test('when true, defers probing to background', () => { + const script = ` + process.env.DEFER_RELAY_PROBE = 'true'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + const CONST = await import(root + 'src/const.ts?defer1'); + + console.log('@@RESULT@@' + JSON.stringify({ + deferRelayProbeValue: CONST.DEFER_RELAY_PROBE + })); + process.exit(0); + `; + + const result = runRouteScript(script, { DEFER_RELAY_PROBE: 'true' }); + expect(result.deferRelayProbeValue).toBe(true); + }, { timeout: 10000 }); + + test('when false or unset, does not defer', () => { + const script = ` + process.env.DEFER_RELAY_PROBE = 'false'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + const CONST = await import(root + 'src/const.ts?defer2'); + + console.log('@@RESULT@@' + JSON.stringify({ + deferRelayProbeValue: CONST.DEFER_RELAY_PROBE + })); + process.exit(0); + `; + + const result = runRouteScript(script, { DEFER_RELAY_PROBE: 'false' }); + expect(result.deferRelayProbeValue).toBe(false); + }, { timeout: 10000 }); + }); + + describe('filterRelaysForKindSupport function', () => { + test('returns empty array for empty input', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const { filterRelaysForKindSupport } = await import(root + 'src/node/manager.ts'); + + const result = await filterRelaysForKindSupport([], 20004); + + console.log('@@RESULT@@' + JSON.stringify({ + result, + isEmpty: result.length === 0 + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.isEmpty).toBe(true); + }, { timeout: 10000 }); + + test('returns empty array for null/undefined input', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const { filterRelaysForKindSupport } = await import(root + 'src/node/manager.ts'); + + const result1 = await filterRelaysForKindSupport(null, 20004); + const result2 = await filterRelaysForKindSupport(undefined, 20004); + + console.log('@@RESULT@@' + JSON.stringify({ + result1Length: result1.length, + result2Length: result2.length + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.result1Length).toBe(0); + expect(result.result2Length).toBe(0); + }, { timeout: 10000 }); + + test('handles invalid relay URLs gracefully', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const { filterRelaysForKindSupport } = await import(root + 'src/node/manager.ts'); + + let error = null; + let result = []; + try { + result = await filterRelaysForKindSupport(['invalid://not-a-relay'], 20004); + } catch (e) { + error = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ + result, + error, + gracefullyHandled: error === null + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.gracefullyHandled).toBe(true); + }, { timeout: 10000 }); + }); + + describe('Background probe result accessor', () => { + test('getLastBackgroundProbeResult returns null initially', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const manager = await import(root + 'src/node/manager.ts'); + + const hasFunction = typeof manager.getLastBackgroundProbeResult === 'function'; + const initialResult = hasFunction ? manager.getLastBackgroundProbeResult() : 'function_not_found'; + + console.log('@@RESULT@@' + JSON.stringify({ + hasFunction, + initialResult: initialResult === null ? 'null' : String(initialResult) + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasFunction).toBe(true); + expect(result.initialResult).toBe('null'); + }, { timeout: 10000 }); + + test('cancelBackgroundProbe is callable without error', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const manager = await import(root + 'src/node/manager.ts'); + + const hasFunction = typeof manager.cancelBackgroundProbe === 'function'; + let error = null; + try { + if (hasFunction) { + manager.cancelBackgroundProbe(); + } + } catch (e) { + error = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ + hasFunction, + error + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasFunction).toBe(true); + expect(result.error).toBe(null); + }, { timeout: 10000 }); + + test('cancelBackgroundProbe can be called multiple times safely', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const manager = await import(root + 'src/node/manager.ts'); + + let errors = []; + for (let i = 0; i < 3; i++) { + try { + manager.cancelBackgroundProbe(); + } catch (e) { + errors.push(e.message); + } + } + + console.log('@@RESULT@@' + JSON.stringify({ + errorCount: errors.length, + errors + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.errorCount).toBe(0); + }, { timeout: 10000 }); + }); + + describe('cleanupMonitoring integration', () => { + test('cleanupMonitoring calls cancelBackgroundProbe', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const manager = await import(root + 'src/node/manager.ts'); + + const hasCleanup = typeof manager.cleanupMonitoring === 'function'; + let error = null; + try { + if (hasCleanup) { + manager.cleanupMonitoring(); + } + } catch (e) { + error = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ + hasCleanup, + error + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasCleanup).toBe(true); + expect(result.error).toBe(null); + }, { timeout: 10000 }); + }); + + describe('Const exports', () => { + test('SKIP_RELAY_PROBE and DEFER_RELAY_PROBE are exported', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const CONST = await import(root + 'src/const.ts?exports'); + + console.log('@@RESULT@@' + JSON.stringify({ + hasSkipRelayProbe: 'SKIP_RELAY_PROBE' in CONST, + hasDeferRelayProbe: 'DEFER_RELAY_PROBE' in CONST, + skipType: typeof CONST.SKIP_RELAY_PROBE, + deferType: typeof CONST.DEFER_RELAY_PROBE + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasSkipRelayProbe).toBe(true); + expect(result.hasDeferRelayProbe).toBe(true); + expect(result.skipType).toBe('boolean'); + expect(result.deferType).toBe('boolean'); + }, { timeout: 10000 }); + }); +}); diff --git a/tests/routes/static-imports.spec.ts b/tests/routes/static-imports.spec.ts new file mode 100644 index 0000000..72d6ade --- /dev/null +++ b/tests/routes/static-imports.spec.ts @@ -0,0 +1,373 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { pathToFileURL } from 'url'; +import { runRouteScript } from './helpers/script-runner'; + +const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; + +afterEach(() => { + delete process.env.NODE_ENV; + delete process.env.HEADLESS; + delete process.env.AUTH_ENABLED; + delete process.env.API_KEY; + delete process.env.RATE_LIMIT_ENABLED; + delete process.env.SESSION_SECRET; +}); + +describe('Static imports optimization (1.1)', () => { + describe('Auth functions availability', () => { + test('authenticate is immediately available without await', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-key'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const auth = await import(root + 'src/routes/auth.ts'); + + const hasAuthenticate = typeof auth.authenticate === 'function'; + const hasAuthConfig = typeof auth.AUTH_CONFIG === 'object'; + const hasCheckRateLimit = typeof auth.checkRateLimit === 'function'; + const hasStopAuthCleanup = typeof auth.stopAuthCleanup === 'function'; + + console.log('@@RESULT@@' + JSON.stringify({ + hasAuthenticate, + hasAuthConfig, + hasCheckRateLimit, + hasStopAuthCleanup + })); + auth.stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasAuthenticate).toBe(true); + expect(result.hasAuthConfig).toBe(true); + expect(result.hasCheckRateLimit).toBe(true); + expect(result.hasStopAuthCleanup).toBe(true); + }, { timeout: 10000 }); + + test('AUTH_CONFIG has expected properties', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-key-123'; + process.env.RATE_LIMIT_ENABLED = 'true'; + + const { AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + hasEnabled: 'ENABLED' in AUTH_CONFIG, + hasRateLimitEnabled: 'RATE_LIMIT_ENABLED' in AUTH_CONFIG, + hasSessionTimeout: 'SESSION_TIMEOUT' in AUTH_CONFIG, + enabledValue: AUTH_CONFIG.ENABLED, + rateLimitValue: AUTH_CONFIG.RATE_LIMIT_ENABLED + })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.hasEnabled).toBe(true); + expect(result.hasRateLimitEnabled).toBe(true); + expect(result.hasSessionTimeout).toBe(true); + expect(result.enabledValue).toBe(true); + expect(result.rateLimitValue).toBe(true); + }, { timeout: 10000 }); + }); + + describe('Rate limiting functionality', () => { + test('checkRateLimit works correctly with in-memory store', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-key'; + process.env.RATE_LIMIT_ENABLED = 'true'; + + const { checkRateLimit, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + const req = new Request('http://localhost/api/test', { + headers: { 'X-Forwarded-For': '192.168.1.100' } + }); + + const result = await checkRateLimit(req, 'test-bucket', { + clientIp: '192.168.1.100', + windowMs: 60000, + max: 10 + }); + + console.log('@@RESULT@@' + JSON.stringify({ + allowed: result.allowed, + hasRemaining: 'remaining' in result + })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.allowed).toBe(true); + expect(result.hasRemaining).toBe(true); + }, { timeout: 10000 }); + + test('rate limiting respects max attempts', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-key'; + process.env.RATE_LIMIT_ENABLED = 'true'; + + const { checkRateLimit, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + const results = []; + const uniqueIp = '10.0.0.' + Math.floor(Math.random() * 255); + // Use a fixed bucket name for all requests in this test + const testBucket = 'rate-limit-test-' + Date.now(); + + for (let i = 0; i < 5; i++) { + const req = new Request('http://localhost/api/test'); + const rl = await checkRateLimit(req, testBucket, { + clientIp: uniqueIp, + windowMs: 60000, + max: 3 + }); + results.push({ attempt: i + 1, allowed: rl.allowed, remaining: rl.remaining }); + } + + console.log('@@RESULT@@' + JSON.stringify({ results })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + // First 3 should be allowed, 4th and 5th should be denied + expect(result.results[0].allowed).toBe(true); + expect(result.results[1].allowed).toBe(true); + expect(result.results[2].allowed).toBe(true); + expect(result.results[3].allowed).toBe(false); + expect(result.results[4].allowed).toBe(false); + }, { timeout: 10000 }); + }); + + describe('Shutdown cleanup functions', () => { + test('cleanupRateLimiter is callable without error', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const { cleanupRateLimiter, getRateLimiter } = await import(root + 'src/utils/rate-limiter.ts'); + + // Initialize the rate limiter first + getRateLimiter(); + + let cleanupError = null; + try { + cleanupRateLimiter(); + } catch (e) { + cleanupError = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ cleanupError })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.cleanupError).toBe(null); + }, { timeout: 10000 }); + + test('stopAuthCleanup is callable without error', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.SESSION_SECRET = 'a'.repeat(64); + + const { stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + let cleanupError = null; + try { + stopAuthCleanup(); + } catch (e) { + cleanupError = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ cleanupError })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.cleanupError).toBe(null); + }, { timeout: 10000 }); + + test('stopAuthCleanup can be called multiple times safely', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.SESSION_SECRET = 'b'.repeat(64); + + const { stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + let errors = []; + for (let i = 0; i < 3; i++) { + try { + stopAuthCleanup(); + } catch (e) { + errors.push(e.message); + } + } + + console.log('@@RESULT@@' + JSON.stringify({ errorCount: errors.length, errors })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.errorCount).toBe(0); + }, { timeout: 10000 }); + }); + + describe('HEADLESS mode compatibility', () => { + test('auth works in headless mode with API key', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'my-headless-key'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const { authenticate, AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + const req = new Request('http://localhost/api/status', { + headers: { 'X-API-Key': 'my-headless-key' } + }); + + const result = await authenticate(req); + + console.log('@@RESULT@@' + JSON.stringify({ + authenticated: result.authenticated, + userId: result.userId, + authEnabled: AUTH_CONFIG.ENABLED + })); + + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.authenticated).toBe(true); + expect(result.userId).toBe('api-user'); + expect(result.authEnabled).toBe(true); + }, { timeout: 10000 }); + + test('auth rejects invalid API key in headless mode', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'correct-key'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const { authenticate, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + const req = new Request('http://localhost/api/status', { + headers: { 'X-API-Key': 'wrong-key' } + }); + + const result = await authenticate(req); + + console.log('@@RESULT@@' + JSON.stringify({ + authenticated: result.authenticated, + hasError: !!result.error + })); + + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.authenticated).toBe(false); + expect(result.hasError).toBe(true); + }, { timeout: 10000 }); + + test('auth allows anonymous when AUTH_ENABLED is false', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'false'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const { authenticate, AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + const req = new Request('http://localhost/api/status'); + const result = await authenticate(req); + + console.log('@@RESULT@@' + JSON.stringify({ + authenticated: result.authenticated, + userId: result.userId, + authEnabled: AUTH_CONFIG.ENABLED + })); + + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.authenticated).toBe(true); + expect(result.userId).toBe('anonymous'); + expect(result.authEnabled).toBe(false); + }, { timeout: 10000 }); + }); + + describe('Server module imports', () => { + test('server.ts can import auth statically without circular dependency', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'false'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + // This tests that the server module can be imported + // without circular dependency issues after adding static imports + let importError = null; + let hasExports = false; + + try { + // Import the auth module directly (as server.ts does) + const auth = await import(root + 'src/routes/auth.ts'); + const rateLimiter = await import(root + 'src/utils/rate-limiter.ts'); + + hasExports = ( + typeof auth.authenticate === 'function' && + typeof auth.AUTH_CONFIG === 'object' && + typeof auth.checkRateLimit === 'function' && + typeof auth.stopAuthCleanup === 'function' && + typeof rateLimiter.cleanupRateLimiter === 'function' + ); + + auth.stopAuthCleanup(); + rateLimiter.cleanupRateLimiter(); + } catch (e) { + importError = e.message; + } + + console.log('@@RESULT@@' + JSON.stringify({ importError, hasExports })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.importError).toBe(null); + expect(result.hasExports).toBe(true); + }, { timeout: 10000 }); + }); +}); From 04765b6914b4f17105bb278de452f81c98d8229e Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 7 Jan 2026 12:19:51 -0600 Subject: [PATCH 02/69] optimize headless mode startup and memory usage --- src/const.ts | 20 +++ src/node/manager.ts | 9 +- src/routes/auth.ts | 30 ++-- src/server.ts | 16 +- tests/routes/headless-optimizations.spec.ts | 186 ++++++++++++++++++++ tests/routes/onboarding.spec.ts | 1 + 6 files changed, 244 insertions(+), 18 deletions(-) create mode 100644 tests/routes/headless-optimizations.spec.ts diff --git a/src/const.ts b/src/const.ts index 122440c..31e6a4f 100644 --- a/src/const.ts +++ b/src/const.ts @@ -112,3 +112,23 @@ export const DEFER_RELAY_PROBE = (() => { const normalized = trimmed.toLowerCase(); return normalized === 'true' || normalized === '1' || normalized === 'yes'; })(); + +// Skip startup echo broadcasts for faster cold start (perf optimization 5.2) +// When true, skips sendSelfEcho and broadcastShareEcho at headless startup +export const SKIP_STARTUP_ECHO = (() => { + const value = process.env['SKIP_STARTUP_ECHO']; + if (!value) return false; + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + const normalized = trimmed.toLowerCase(); + return normalized === 'true' || normalized === '1' || normalized === 'yes'; +})(); + +// Maximum peer status entries before FIFO eviction (perf optimization 2.2) +// Prevents unbounded memory growth in long-running servers +export const MAX_PEER_STATUS_ENTRIES = (() => { + const value = process.env['MAX_PEER_STATUS_ENTRIES']; + if (!value) return 1000; + const parsed = parseInt(value.trim(), 10); + return isNaN(parsed) || parsed < 1 ? 1000 : parsed; +})(); diff --git a/src/node/manager.ts b/src/node/manager.ts index 8de58cf..1a6a442 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -15,7 +15,7 @@ import type { NodePolicyInput, NodeEventConfig, EnhancedNodeConfig } from '@fros import { randomBytes } from 'crypto'; import type { ServerBifrostNode, PeerStatus, PingResult } from '../routes/types.js'; import { getValidRelays, safeStringify, getOpTimeoutMs } from '../routes/utils.js'; -import { SKIP_RELAY_PROBE, DEFER_RELAY_PROBE } from '../const.js'; +import { SKIP_RELAY_PROBE, DEFER_RELAY_PROBE, MAX_PEER_STATUS_ENTRIES } from '../const.js'; import { loadFallbackPeerPolicies } from './peer-policy-store.js'; import { mergePolicyInputs } from '../util/peer-policy.js'; import type { ServerWebSocket } from 'bun'; @@ -1509,7 +1509,12 @@ export function setupNodeEventListeners( latency: latency || existingStatus?.latency, lastPingAttempt: existingStatus?.lastPingAttempt }; - + + // FIFO eviction: if at capacity and this is a new key, evict oldest-inserted (perf optimization 2.2) + if (peerStatuses.size >= MAX_PEER_STATUS_ENTRIES && !existingStatus) { + const oldestKey = peerStatuses.keys().next().value; + if (oldestKey) peerStatuses.delete(oldestKey); + } peerStatuses.set(normalizedPubkey, updatedStatus); // Broadcast peer status update for peer list (not logged to event stream) diff --git a/src/routes/auth.ts b/src/routes/auth.ts index c96834f..d496161 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -170,9 +170,16 @@ function loadOrGenerateSessionSecret(): string | null { // Validate SESSION_SECRET configuration function validateSessionSecret(): string | null { + // Fast path: headless with API key doesn't need sessions (perf optimization 3.2) + // Skip file I/O for session secret when using API key auth + if (HEADLESS && process.env.API_KEY) { + console.log('Headless mode with API_KEY: session management disabled'); + return null; + } + let sessionSecret = process.env.SESSION_SECRET; const isProduction = process.env.NODE_ENV === 'production'; - + // If no SESSION_SECRET provided, attempt to auto-generate or load if (!sessionSecret) { const generatedSecret = loadOrGenerateSessionSecret(); @@ -778,15 +785,18 @@ const CLEANUP_INTERVAL = 10 * 60 * 1000; let sessionCleanupTimer: ReturnType | null = null; let vaultCleanupTimer: ReturnType | null = null; -// Start session cleanup timer -sessionCleanupTimer = setInterval(() => { - void cleanupExpiredSessions(); -}, CLEANUP_INTERVAL); - -// Start vault cleanup timer -vaultCleanupTimer = setInterval(() => { - cleanupExpiredVaultEntries(); -}, VAULT_CLEANUP_INTERVAL_MS); +// Skip timers in headless mode with API key - sessions are disabled anyway (perf optimization 2.3) +if (!HEADLESS || !process.env.API_KEY) { + // Start session cleanup timer + sessionCleanupTimer = setInterval(() => { + void cleanupExpiredSessions(); + }, CLEANUP_INTERVAL); + + // Start vault cleanup timer + vaultCleanupTimer = setInterval(() => { + cleanupExpiredVaultEntries(); + }, VAULT_CLEANUP_INTERVAL_MS); +} // Export cleanup function for graceful shutdown export function stopAuthCleanup(): void { diff --git a/src/server.ts b/src/server.ts index b3842e0..fa0e44e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -216,11 +216,14 @@ const restartState = { blockedByCredentials: false }; // Create event management functions const broadcastEvent = createBroadcastEvent(eventStreams); const addServerLog = createAddServerLog(broadcastEvent); -initNip46Service({ - addServerLog, - broadcastEvent, - getNode: () => node -}); +// NIP-46 service only needed in database mode (perf optimization 3.3) +if (!CONST.HEADLESS) { + initNip46Service({ + addServerLog, + broadcastEvent, + getNode: () => node + }); +} // Removed global nostr-tools SimplePool monkey-patch in favor of proxy-based instrumentation // See: src/node/manager.ts createInstrumentedNode/createInstrumentedClient/createInstrumentedPool @@ -536,7 +539,8 @@ if (CONST.hasCredentials()) { scheduleRestartWithBackoff('watchdog timeout'); }, activeCredentials?.group, activeCredentials?.share); - if (CONST.HEADLESS) { + // Startup echo broadcasts verify connectivity (perf optimization 5.2: skippable) + if (CONST.HEADLESS && !CONST.SKIP_STARTUP_ECHO) { const echoOptions = { relaysEnv: process.env.RELAYS, addServerLog, diff --git a/tests/routes/headless-optimizations.spec.ts b/tests/routes/headless-optimizations.spec.ts new file mode 100644 index 0000000..672dda5 --- /dev/null +++ b/tests/routes/headless-optimizations.spec.ts @@ -0,0 +1,186 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { pathToFileURL } from 'url'; +import { runRouteScript } from './helpers/script-runner'; + +const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; + +afterEach(() => { + delete process.env.NODE_ENV; + delete process.env.HEADLESS; + delete process.env.AUTH_ENABLED; + delete process.env.API_KEY; + delete process.env.RATE_LIMIT_ENABLED; + delete process.env.SESSION_SECRET; + delete process.env.SKIP_STARTUP_ECHO; +}); + +describe('Headless performance optimizations', () => { + describe('SKIP_STARTUP_ECHO const (5.2)', () => { + test('defaults to false when not set', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + delete process.env.SKIP_STARTUP_ECHO; + + const CONST = await import(root + 'src/const.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipStartupEcho: CONST.SKIP_STARTUP_ECHO + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.skipStartupEcho).toBe(false); + }, { timeout: 10000 }); + + test('parses "true" correctly', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.SKIP_STARTUP_ECHO = 'true'; + + // Force re-import by using dynamic import with cache bust + const CONST = await import(root + 'src/const.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipStartupEcho: CONST.SKIP_STARTUP_ECHO + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.skipStartupEcho).toBe(true); + }, { timeout: 10000 }); + + test('parses "1" correctly', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.SKIP_STARTUP_ECHO = '1'; + + const CONST = await import(root + 'src/const.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipStartupEcho: CONST.SKIP_STARTUP_ECHO + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.skipStartupEcho).toBe(true); + }, { timeout: 10000 }); + + test('treats empty string as false', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.SKIP_STARTUP_ECHO = ''; + + const CONST = await import(root + 'src/const.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + skipStartupEcho: CONST.SKIP_STARTUP_ECHO + })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.skipStartupEcho).toBe(false); + }, { timeout: 10000 }); + }); + + describe('Session secret skip in headless+API_KEY mode (3.2)', () => { + test('skips session secret generation when HEADLESS=true and API_KEY set', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-api-key'; + process.env.RATE_LIMIT_ENABLED = 'false'; + // Explicitly NOT setting SESSION_SECRET + + const { AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + sessionSecretIsNull: AUTH_CONFIG.SESSION_SECRET === null, + hasApiKey: AUTH_CONFIG.API_KEY === 'test-api-key' + })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.sessionSecretIsNull).toBe(true); + expect(result.hasApiKey).toBe(true); + }, { timeout: 10000 }); + + test('still generates session secret when HEADLESS=false', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'false'; + process.env.AUTH_ENABLED = 'true'; + process.env.API_KEY = 'test-api-key'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const { AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + sessionSecretExists: typeof AUTH_CONFIG.SESSION_SECRET === 'string' && AUTH_CONFIG.SESSION_SECRET.length > 0 + })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.sessionSecretExists).toBe(true); + }, { timeout: 10000 }); + + test('still generates session secret when API_KEY not set', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'false'; + process.env.RATE_LIMIT_ENABLED = 'false'; + // Not setting API_KEY + + const { AUTH_CONFIG, stopAuthCleanup } = await import(root + 'src/routes/auth.ts'); + + console.log('@@RESULT@@' + JSON.stringify({ + sessionSecretExists: typeof AUTH_CONFIG.SESSION_SECRET === 'string' && AUTH_CONFIG.SESSION_SECRET.length > 0 + })); + stopAuthCleanup(); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.sessionSecretExists).toBe(true); + }, { timeout: 10000 }); + }); + + describe('NIP-46 service gated in headless mode (3.3)', () => { + test('getNip46Service returns null in headless mode', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + process.env.AUTH_ENABLED = 'false'; + process.env.RATE_LIMIT_ENABLED = 'false'; + + const { getNip46Service } = await import(root + 'src/nip46/index.ts'); + + // In headless mode, the service should not be initialized + // (server.ts gates initNip46Service on !HEADLESS) + const service = getNip46Service(); + + console.log('@@RESULT@@' + JSON.stringify({ + serviceIsNull: service === null || service === undefined + })); + process.exit(0); + `; + + const result = runRouteScript(script); + // Service should be null/undefined since it was never initialized + expect(result.serviceIsNull).toBe(true); + }, { timeout: 10000 }); + }); +}); diff --git a/tests/routes/onboarding.spec.ts b/tests/routes/onboarding.spec.ts index 8376a95..c994917 100644 --- a/tests/routes/onboarding.spec.ts +++ b/tests/routes/onboarding.spec.ts @@ -49,6 +49,7 @@ describe('Onboarding routes', () => { process.env.HEADLESS = 'false'; process.env.DB_PATH = ${JSON.stringify(dbPath)}; process.env.ADMIN_SECRET = 'integration-secret'; + process.env.SKIP_ADMIN_SECRET_VALIDATION = 'false'; const { handleOnboardingRoute } = await import(root + 'src/routes/onboarding.ts'); const context = { From f95f705a16db625020f2ae4177d136b41df230e6 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 7 Jan 2026 12:28:17 -0600 Subject: [PATCH 03/69] type fixes --- bun.lock | 3 +++ package.json | 7 +++++-- src/node/manager.ts | 16 ++++++++++------ 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/bun.lock b/bun.lock index 67c12a2..0dc4d79 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3", }, }, }, @@ -769,6 +770,8 @@ "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], diff --git a/package.json b/package.json index e6a03b9..dd224dc 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,9 @@ "api:test:cors": "bun scripts/api/test-cors-preflight.ts", "api:test:get:openapi": "bun scripts/api/test-get-openapi-sweep.ts", "api:test:ws": "bun scripts/api/test-ws-events.ts", - "api:test:nip": "bun scripts/api/test-nip44-nip04.ts" + "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", + "typecheck": "tsc --noEmit", + "tsc": "tsc --noEmit" }, "dependencies": { "@cmdcode/buff": "^2.2.5", @@ -66,6 +68,7 @@ "esbuild": "^0.24.2", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.3" } } diff --git a/src/node/manager.ts b/src/node/manager.ts index 1a6a442..249769b 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -644,7 +644,8 @@ let nextRecreationBackoffMs = 60000; // Start with 1 minute backoff let nextRecreationAllowedAt = 0; // Timestamp when next recreation is allowed // Background relay probe state (perf optimization 3.1) -let backgroundProbePromise: Promise | null = null; +// Note: _backgroundProbePromise tracks the running probe for potential future use (e.g., awaiting on shutdown) +let _backgroundProbePromise: Promise | null = null; let backgroundProbeGeneration = 0; interface BackgroundProbeResult { @@ -752,7 +753,7 @@ async function runBackgroundRelayProbe( } } finally { if (backgroundProbeGeneration === myGeneration) { - backgroundProbePromise = null; + _backgroundProbePromise = null; } } } @@ -767,9 +768,12 @@ export function getLastBackgroundProbeResult(): BackgroundProbeResult | null { /** * Cancel any running background probe by clearing the promise reference. * Note: This does not abort in-flight relay checks; they will complete naturally. + * @returns true if there was a running probe to cancel, false otherwise */ -export function cancelBackgroundProbe(): void { - backgroundProbePromise = null; +export function cancelBackgroundProbe(): boolean { + const hadProbe = _backgroundProbePromise !== null; + _backgroundProbePromise = null; + return hadProbe; } // Helper function to update node activity @@ -2265,7 +2269,7 @@ export async function createNodeWithCredentials( // Start background probe if deferred (perf optimization 3.1) if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { - backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + _backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); } return wrappedNode; @@ -2300,7 +2304,7 @@ export async function createNodeWithCredentials( // Start background probe if deferred (perf optimization 3.1) if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { - backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + _backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); } return wrappedNode; From 22d516a9c6de8a97a1add7236c759439db634d28 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 7 Jan 2026 12:39:04 -0600 Subject: [PATCH 04/69] type fix --- src/node/manager.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/node/manager.ts b/src/node/manager.ts index 249769b..0c9b0fc 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -644,8 +644,7 @@ let nextRecreationBackoffMs = 60000; // Start with 1 minute backoff let nextRecreationAllowedAt = 0; // Timestamp when next recreation is allowed // Background relay probe state (perf optimization 3.1) -// Note: _backgroundProbePromise tracks the running probe for potential future use (e.g., awaiting on shutdown) -let _backgroundProbePromise: Promise | null = null; +// Generation counter prevents stale probe results from being stored after cancellation let backgroundProbeGeneration = 0; interface BackgroundProbeResult { @@ -720,7 +719,14 @@ async function runBackgroundRelayProbe( const filtered = await filterRelaysForKindSupport(relays, kind, addServerLog); - // Store result for potential future use + // Only store result if this probe is still current (not cancelled or superseded) + if (backgroundProbeGeneration !== myGeneration) { + if (addServerLog) { + addServerLog('debug', 'Background probe result discarded (superseded by newer probe)'); + } + return; + } + lastBackgroundProbeResult = { originalRelays: relays, filteredRelays: filtered, @@ -751,10 +757,6 @@ async function runBackgroundRelayProbe( error: error instanceof Error ? error.message : String(error) }); } - } finally { - if (backgroundProbeGeneration === myGeneration) { - _backgroundProbePromise = null; - } } } @@ -766,14 +768,12 @@ export function getLastBackgroundProbeResult(): BackgroundProbeResult | null { } /** - * Cancel any running background probe by clearing the promise reference. + * Cancel any running background probe by bumping the generation counter. + * This causes in-flight probes to be considered stale (their results won't be stored). * Note: This does not abort in-flight relay checks; they will complete naturally. - * @returns true if there was a running probe to cancel, false otherwise */ -export function cancelBackgroundProbe(): boolean { - const hadProbe = _backgroundProbePromise !== null; - _backgroundProbePromise = null; - return hadProbe; +export function cancelBackgroundProbe(): void { + backgroundProbeGeneration++; } // Helper function to update node activity @@ -2269,7 +2269,7 @@ export async function createNodeWithCredentials( // Start background probe if deferred (perf optimization 3.1) if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { - _backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + void runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); } return wrappedNode; @@ -2304,7 +2304,7 @@ export async function createNodeWithCredentials( // Start background probe if deferred (perf optimization 3.1) if (DEFER_RELAY_PROBE && !SKIP_RELAY_PROBE) { - _backgroundProbePromise = runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); + void runBackgroundRelayProbe(relaysToProbe, 20004, addServerLog); } return wrappedNode; From 83f6a8e6ed4820ce0897f7bda9a4fd035a2f8d88 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 4 Feb 2026 11:04:16 -0600 Subject: [PATCH 05/69] update available banner --- CHANGELOG.md | 10 + frontend/App.tsx | 35 ++- frontend/components/Login.tsx | 6 +- frontend/components/ui/update-banner.tsx | 37 +++ frontend/types/index.ts | 13 +- package.json | 2 +- src/routes/index.ts | 6 +- src/routes/update.ts | 282 +++++++++++++++++++++++ 8 files changed, 385 insertions(+), 6 deletions(-) create mode 100644 frontend/components/ui/update-banner.tsx create mode 100644 src/routes/update.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e98a3..47699e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # CHANGELOG +## [1.1.1] — 2026-02-04 + +### Added + +* Update check banner for non-managed installs, backed by `/api/update`. + +### Notes + +* Version bump commit: **1.1.1**. + ## [1.1.0] — 2025-12-10 ### Highlights diff --git a/frontend/App.tsx b/frontend/App.tsx index 7f5dbcb..f8cb877 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -6,12 +6,13 @@ import { NIP46 } from "./components/NIP46" import ApiKeys from "./components/ApiKeys" import Login from "./components/Login" import Onboarding from "./components/Onboarding" -import type { SignerHandle } from "./types" +import type { SignerHandle, UpdateInfo } from "./types" import { Button } from "./components/ui/button" import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { PageLayout } from "./components/ui/page-layout" import { AppHeader } from "./components/ui/app-header" import { ContentCard } from "./components/ui/content-card" +import { UpdateBanner } from "./components/ui/update-banner" import Spinner from "./components/ui/spinner" interface SignerData { @@ -42,6 +43,7 @@ const App: React.FC = () => { const [initializing, setInitializing] = useState(true); // Loading gate used after login to prevent Configure flash const [loadingAppData, setLoadingAppData] = useState(false); + const [updateInfo, setUpdateInfo] = useState(null); const [authState, setAuthState] = useState({ isAuthenticated: false, authEnabled: false @@ -66,6 +68,25 @@ const App: React.FC = () => { initializeApp(); }, []); + useEffect(() => { + let cancelled = false; + const fetchUpdateInfo = async () => { + try { + const response = await fetch('/api/update'); + if (!response.ok) return; + const data = await response.json(); + if (!cancelled) setUpdateInfo(data); + } catch (error) { + console.warn('Update check failed:', error); + } + }; + + fetchUpdateInfo(); + return () => { + cancelled = true; + }; + }, []); + // Global handler for authentication/credentials expiry from child components useEffect(() => { const onAuthExpired = () => { @@ -470,6 +491,7 @@ const App: React.FC = () => { return ( + @@ -490,7 +512,13 @@ const App: React.FC = () => { // Show login screen if authentication is required and user is not authenticated if (authState.authEnabled && !authState.isAuthenticated) { - return ; + return ( + + ); } // After login, while fetching user data, show loading (prevents Configure flash) @@ -502,6 +530,7 @@ const App: React.FC = () => { userId={authState.userId} onLogout={authState.authEnabled ? handleLogout : undefined} /> + @@ -518,6 +547,7 @@ const App: React.FC = () => { userId={authState.userId} onLogout={authState.authEnabled ? handleLogout : undefined} /> + { userId={authState.userId} onLogout={authState.authEnabled ? handleLogout : undefined} /> + void; authEnabled: boolean; + updateInfo?: UpdateInfo | null; } interface AuthStatus { @@ -19,7 +22,7 @@ interface AuthStatus { sessionTimeout: number; } -const Login: React.FC = ({ onLogin, authEnabled }) => { +const Login: React.FC = ({ onLogin, authEnabled, updateInfo }) => { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [apiKey, setApiKey] = useState(''); @@ -104,6 +107,7 @@ const Login: React.FC = ({ onLogin, authEnabled }) => { return ( +
diff --git a/frontend/components/ui/update-banner.tsx b/frontend/components/ui/update-banner.tsx new file mode 100644 index 0000000..481200d --- /dev/null +++ b/frontend/components/ui/update-banner.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Alert } from './alert'; +import { Button } from './button'; +import { cn } from '../../lib/utils'; +import type { UpdateInfo } from '../../types'; + +interface UpdateBannerProps { + info?: UpdateInfo | null; + className?: string; +} + +export const UpdateBanner: React.FC = ({ info, className }) => { + if (!info || !info.enabled || !info.updateAvailable || !info.latestVersion) { + return null; + } + + return ( + +
+
+ You are on v{info.currentVersion}. Latest is v{info.latestVersion}. +
+ {info.releaseUrl && ( + + )} +
+
+ ); +}; diff --git a/frontend/types/index.ts b/frontend/types/index.ts index c0005df..0563d44 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -116,6 +116,17 @@ export interface SignerHandle { checkStatus: () => Promise; } +export interface UpdateInfo { + enabled: boolean; + managedDeployment?: boolean; + currentVersion: string; + latestVersion?: string; + updateAvailable: boolean; + releaseUrl?: string; + checkedAt?: string; + error?: string; +} + export interface SignerProps { initialData?: { share: string; @@ -174,4 +185,4 @@ export interface NobleCipher { export type EventCallback = (data: T) => void; export type BifrostEventCallback = (data: BifrostMessage) => void; export type ECDHEventCallback = (data: BifrostMessage | BifrostMessage[]) => void; -export type SignEventCallback = (data: BifrostMessage | BifrostMessage[]) => void; \ No newline at end of file +export type SignEventCallback = (data: BifrostMessage | BifrostMessage[]) => void; diff --git a/package.json b/package.json index dd224dc..aed086c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "igloo-server", - "version": "1.0.1", + "version": "1.1.1", "scripts": { "start": "bun run src/server.ts", "start:headless": "if [ \"$HEADLESS\" = \"true\" ]; then echo 'Headless mode: skipping frontend build'; else bun run build; fi && bun run start", diff --git a/src/routes/index.ts b/src/routes/index.ts index a0bf467..7a8f4c4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ export { handleSignRoute } from './sign.js'; export { handleNip44Route } from './nip44.js'; export { handleNip04Route } from './nip04.js'; export { handleNip46Route } from './nip46.js'; +export { handleUpdateRoute } from './update.js'; // Export types and utilities export * from './types.js'; @@ -24,6 +25,7 @@ import { handleSignRoute } from './sign.js'; import { handleNip44Route } from './nip44.js'; import { handleNip04Route } from './nip04.js'; import { handleNip46Route } from './nip46.js'; +import { handleUpdateRoute } from './update.js'; import { handleDocsRoute } from './docs.js'; import { handleOnboardingRoute } from './onboarding.js'; import { handleUserRoute } from './user.js'; @@ -174,7 +176,8 @@ export async function handleRequest( '/api/auth/status', '/api/onboarding/status', '/api/onboarding/validate-admin', - '/api/onboarding/setup' + '/api/onboarding/setup', + '/api/update' ]; const isPublicEndpoint = publicEndpoints.some(endpoint => url.pathname === endpoint); @@ -307,6 +310,7 @@ export async function handleRequest( // Note: These handlers now accept auth as an optional parameter const routeHandlers = [ handleStatusRoute, // Allow unauthenticated for health checks + handleUpdateRoute, handlePeersRoute, handleSignRoute, handleNip44Route, diff --git a/src/routes/update.ts b/src/routes/update.ts new file mode 100644 index 0000000..5c0a377 --- /dev/null +++ b/src/routes/update.ts @@ -0,0 +1,282 @@ +import { RouteContext, RequestAuth } from './types.js'; +import { getSecureCorsHeaders, mergeVaryHeaders } from './utils.js'; +import { HEADLESS, SKIP_ADMIN_SECRET_VALIDATION } from '../const.js'; +import { readFileSync } from 'fs'; + +type UpdateSource = 'github-release' | 'github-tags'; + +interface UpdateResult { + latestVersion: string; + releaseUrl?: string; + source: UpdateSource; +} + +interface CachedUpdate { + fetchedAt: number; + result?: UpdateResult; + error?: string; +} + +interface ParsedVersion { + major: number; + minor: number; + patch: number; + prerelease: string | null; + normalized: string; +} + +interface UpdateResponse { + enabled: boolean; + managedDeployment: boolean; + currentVersion: string; + updateAvailable: boolean; + latestVersion?: string; + releaseUrl?: string; + checkedAt?: string; + source?: UpdateSource; + error?: string; +} + +const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10); +const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10); // 6 hours +const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10); // 15 minutes + +const GITHUB_OWNER = 'FROSTR-ORG'; +const GITHUB_REPO = 'igloo-server'; +const RELEASES_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest`; +const TAGS_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/tags?per_page=100`; + +const PACKAGE_JSON_URL = new URL('../../package.json', import.meta.url); + +let cachedVersion: string | null = null; +let cachedUpdate: CachedUpdate | null = null; + +function parseBoolean(value?: string): boolean { + if (!value) return false; + const trimmed = value.trim().toLowerCase(); + return trimmed === 'true' || trimmed === '1' || trimmed === 'yes'; +} + +function getCurrentVersion(): string { + const override = process.env['APP_VERSION']?.trim(); + if (override) return override; + + if (cachedVersion) return cachedVersion; + try { + const raw = readFileSync(PACKAGE_JSON_URL, 'utf8'); + const data = JSON.parse(raw) as { version?: string }; + if (typeof data.version === 'string' && data.version.trim().length > 0) { + cachedVersion = data.version.trim(); + return cachedVersion; + } + } catch (error) { + console.warn('[update-check] Failed to read package.json version:', error); + } + cachedVersion = '0.0.0'; + return cachedVersion; +} + +function parseVersion(raw: string, allowPrerelease: boolean): ParsedVersion | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const withoutPrefix = trimmed.startsWith('v') || trimmed.startsWith('V') + ? trimmed.slice(1) + : trimmed; + const [core, prerelease] = withoutPrefix.split('-', 2); + const parts = core.split('.'); + if (parts.length < 3) return null; + + const major = Number(parts[0]); + const minor = Number(parts[1]); + const patch = Number(parts[2]); + + if (!Number.isFinite(major) || !Number.isFinite(minor) || !Number.isFinite(patch)) { + return null; + } + + if (prerelease && !allowPrerelease) return null; + + return { + major, + minor, + patch, + prerelease: prerelease ?? null, + normalized: `${major}.${minor}.${patch}` + }; +} + +function compareVersions(a: ParsedVersion, b: ParsedVersion): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + + if (a.prerelease && !b.prerelease) return -1; + if (!a.prerelease && b.prerelease) return 1; + if (a.prerelease && b.prerelease) return a.prerelease.localeCompare(b.prerelease); + + return 0; +} + +function isCacheValid(cache: CachedUpdate): boolean { + const ttl = cache.error ? UPDATE_CHECK_FAILURE_TTL_MS : UPDATE_CHECK_TTL_MS; + return Date.now() - cache.fetchedAt < ttl; +} + +function buildHeaders(): HeadersInit { + const headers: HeadersInit = { + 'Accept': 'application/vnd.github+json', + 'User-Agent': 'igloo-server' + }; + const token = process.env['GITHUB_TOKEN']?.trim(); + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +async function fetchWithTimeout(url: string, options: RequestInit): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), UPDATE_CHECK_TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +async function fetchLatestVersion(): Promise { + const headers = buildHeaders(); + const releaseResponse = await fetchWithTimeout(RELEASES_URL, { headers }); + + if (releaseResponse.ok) { + const payload = await releaseResponse.json() as { tag_name?: string; html_url?: string }; + const tagName = typeof payload.tag_name === 'string' ? payload.tag_name : ''; + const parsed = parseVersion(tagName, true); + if (parsed) { + return { + latestVersion: parsed.normalized, + releaseUrl: typeof payload.html_url === 'string' ? payload.html_url : undefined, + source: 'github-release' + }; + } + } + + const tagsResponse = await fetchWithTimeout(TAGS_URL, { headers }); + if (!tagsResponse.ok) { + throw new Error(`GitHub tag fetch failed with status ${tagsResponse.status}`); + } + + const tags = await tagsResponse.json() as Array<{ name?: string }>; + let latest: ParsedVersion | null = null; + let latestRaw: string | null = null; + + for (const tag of tags) { + if (!tag?.name) continue; + const parsed = parseVersion(tag.name, false); + if (!parsed) continue; + if (!latest || compareVersions(parsed, latest) > 0) { + latest = parsed; + latestRaw = tag.name; + } + } + + if (!latest) { + throw new Error('No valid semver tags found in GitHub response.'); + } + + const tagName = latestRaw ?? `v${latest.normalized}`; + return { + latestVersion: latest.normalized, + releaseUrl: `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/tree/${tagName}`, + source: 'github-tags' + }; +} + +export async function handleUpdateRoute( + req: Request, + url: URL, + _context: RouteContext, + _auth?: RequestAuth | null +): Promise { + if (url.pathname !== '/api/update') return null; + + const corsHeaders = getSecureCorsHeaders(req); + const mergedVary = mergeVaryHeaders(corsHeaders); + + const headers = { + 'Content-Type': 'application/json', + ...corsHeaders, + 'Vary': mergedVary, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key, X-Session-ID', + }; + + if (req.method === 'OPTIONS') { + return new Response(null, { status: 204, headers }); + } + + if (req.method !== 'GET') { + return Response.json({ error: 'Method not allowed' }, { status: 405, headers }); + } + + const managedDeployment = HEADLESS || SKIP_ADMIN_SECRET_VALIDATION || parseBoolean(process.env['MANAGED_DEPLOYMENT']); + const updateCheckDisabled = parseBoolean(process.env['UPDATE_CHECK_DISABLED']); + const updateCheckEnabled = !managedDeployment && !updateCheckDisabled; + const currentVersion = getCurrentVersion(); + + if (!updateCheckEnabled) { + const response: UpdateResponse = { + enabled: false, + managedDeployment, + currentVersion, + updateAvailable: false + }; + return Response.json(response, { headers }); + } + + if (!cachedUpdate || !isCacheValid(cachedUpdate)) { + try { + const result = await fetchLatestVersion(); + cachedUpdate = { fetchedAt: Date.now(), result }; + } catch (error) { + cachedUpdate = { + fetchedAt: Date.now(), + error: error instanceof Error ? error.message : 'Unknown update check error' + }; + } + } + + if (!cachedUpdate || !cachedUpdate.result) { + const response: UpdateResponse = { + enabled: true, + managedDeployment, + currentVersion, + updateAvailable: false, + checkedAt: new Date(cachedUpdate?.fetchedAt ?? Date.now()).toISOString(), + error: cachedUpdate?.error ?? 'Update check unavailable' + }; + return Response.json(response, { headers }); + } + + const currentParsed = parseVersion(currentVersion, true); + const latestParsed = parseVersion(cachedUpdate.result.latestVersion, true); + + const updateAvailable = Boolean( + currentParsed && + latestParsed && + compareVersions(latestParsed, currentParsed) > 0 + ); + + const response: UpdateResponse = { + enabled: true, + managedDeployment, + currentVersion, + updateAvailable, + latestVersion: cachedUpdate.result.latestVersion, + releaseUrl: cachedUpdate.result.releaseUrl, + source: cachedUpdate.result.source, + checkedAt: new Date(cachedUpdate.fetchedAt).toISOString() + }; + + return Response.json(response, { headers }); +} From 3804a90503b4c9c1440b2caf765c21a2caafd32c Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 4 Feb 2026 11:12:41 -0600 Subject: [PATCH 06/69] fix type --- frontend/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/App.tsx b/frontend/App.tsx index f8cb877..dab7bd4 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -74,7 +74,7 @@ const App: React.FC = () => { try { const response = await fetch('/api/update'); if (!response.ok) return; - const data = await response.json(); + const data = await response.json() as UpdateInfo; if (!cancelled) setUpdateInfo(data); } catch (error) { console.warn('Update check failed:', error); From b5700aade75e5df5a56162bc82f963a2b52cd76e Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 4 Feb 2026 12:48:16 -0600 Subject: [PATCH 07/69] fix import --- frontend/App.tsx | 2 +- frontend/components/Login.tsx | 2 +- frontend/components/ui/{update-banner.tsx => UpdateBanner.tsx} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename frontend/components/ui/{update-banner.tsx => UpdateBanner.tsx} (100%) diff --git a/frontend/App.tsx b/frontend/App.tsx index dab7bd4..8874f96 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs" import { PageLayout } from "./components/ui/page-layout" import { AppHeader } from "./components/ui/app-header" import { ContentCard } from "./components/ui/content-card" -import { UpdateBanner } from "./components/ui/update-banner" +import { UpdateBanner } from "./components/ui/UpdateBanner" import Spinner from "./components/ui/spinner" interface SignerData { diff --git a/frontend/components/Login.tsx b/frontend/components/Login.tsx index f0b8368..6feec48 100644 --- a/frontend/components/Login.tsx +++ b/frontend/components/Login.tsx @@ -5,7 +5,7 @@ import { PageLayout } from './ui/page-layout'; import { AppHeader } from './ui/app-header'; import { ContentCard } from './ui/content-card'; import { Alert } from './ui/alert'; -import { UpdateBanner } from './ui/update-banner'; +import { UpdateBanner } from './ui/UpdateBanner'; import Spinner from './ui/spinner'; import type { UpdateInfo } from '../types'; diff --git a/frontend/components/ui/update-banner.tsx b/frontend/components/ui/UpdateBanner.tsx similarity index 100% rename from frontend/components/ui/update-banner.tsx rename to frontend/components/ui/UpdateBanner.tsx From a64e349db50bac64f3e3f64d5d0525c55f64721f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 5 Feb 2026 11:18:29 -0600 Subject: [PATCH 08/69] documentation updateds and polish --- llm/context/API_KEYS.md | 34 +-- llm/context/ENVIRONMENT_VARIABLES.md | 196 ++++++++++-------- llm/context/NIP46_IMPLEMENTATION.md | 55 +++-- llm/implementation/auth-implementation.md | 140 +++++++++++++ .../credential-storage-implementation.md | 80 +++++++ .../node-lifecycle-implementation.md | 91 ++++++++ llm/implementation/session-management.md | 7 + llm/implementation/umbrel-implementation.md | 77 +++++++ llm/implementation/umbrel-status.md | 28 --- 9 files changed, 552 insertions(+), 156 deletions(-) create mode 100644 llm/implementation/auth-implementation.md create mode 100644 llm/implementation/credential-storage-implementation.md create mode 100644 llm/implementation/node-lifecycle-implementation.md create mode 100644 llm/implementation/session-management.md create mode 100644 llm/implementation/umbrel-implementation.md delete mode 100644 llm/implementation/umbrel-status.md diff --git a/llm/context/API_KEYS.md b/llm/context/API_KEYS.md index 65574dc..4289f79 100644 --- a/llm/context/API_KEYS.md +++ b/llm/context/API_KEYS.md @@ -1,4 +1,4 @@ -# API Keys Deep‑Dive: Design, Usage, and Operations +# API Keys Deep-Dive: Design, Usage, and Operations This document explains how API keys work in Igloo Server across both headless and database modes. It covers generation, storage, authentication, auditing fields, admin APIs, UI behavior, security posture, and recommended operations. @@ -7,17 +7,19 @@ This document explains how API keys work in Igloo Server across both headless an - Headless Mode (`HEADLESS=true`) - Single API key provided via the environment: `API_KEY`. - No HTTP creation/rotation; `/api/env` intentionally cannot set `API_KEY`. + - When `API_KEY` is set, session auth is disabled (no session fallback). - Use for automation and simple deployments; the UI “API Keys” tab is disabled. - Database Mode (`HEADLESS=false`) - Multiple keys stored in SQLite (`api_keys` table). - Create/list/revoke via Admin API or the UI “API Keys” tab. - - Admin authentication accepts either `ADMIN_SECRET` bearer or an authenticated admin session. + - Admin authentication accepts either `ADMIN_SECRET` bearer or an authenticated **admin** session (`role=admin`). + - API key auth is only enabled when at least one active DB key exists. ## 2) Token Format & Storage Model - Generation - - 32 random bytes → 64‑char hexadecimal token. + - 32 random bytes -> 64-char hexadecimal token. - Public `prefix` = first 12 characters; used for display and initial DB lookup. - Storage @@ -27,7 +29,7 @@ This document explains how API keys work in Igloo Server across both headless an - Verification - Extract token, derive `prefix`, fetch candidate row by `prefix`. - - Timing‑safe compare of `sha256(token)` with stored `key_hash` (normalized to 32 bytes). + - Timing-safe compare of `sha256(token)` with stored `key_hash` (normalized to 32 bytes). ## 3) Database Schema (SQLite) @@ -58,7 +60,7 @@ Ordering in listings: active first, then `created_at DESC, id DESC`. Authentication options (any of): - `Authorization: Bearer ` -- Logged‑in admin session (`X-Session-ID` or session cookie) — accepts first user or users with `role=admin`. +- Logged-in admin session (`X-Session-ID` or session cookie) — requires `role=admin` (the first user is admin by default). Endpoints @@ -75,23 +77,23 @@ Rate limiting protects these endpoints from brute force; 429 includes `Retry-Aft ## 5) Request Authentication Pipeline -Order of attempts (non‑headless): +Order of attempts (non-headless): -1. API Key (DB‑backed) — `X-API-Key` or `Authorization: Bearer`. +1. API Key (DB-backed) — `X-API-Key` or `Authorization: Bearer`. 2. Basic Auth (if configured). 3. Session (`X-Session-ID` header or cookie) — used by the UI. -On successful DB API‑key auth, the server updates `last_used_at` and, when available, `last_used_ip`. +On successful DB API-key auth, the server updates `last_used_at` and, when available, `last_used_ip`. The authenticated `userId` is `api-key:` (string), so it is treated as an env-auth user and cannot access `/api/user/*`. Client IP attribution precedence: -- `X-Forwarded-For` (left‑most), then `X-Real-IP`, then `CF-Connecting-IP`, else `unknown`. +- `X-Forwarded-For` (left-most), then `X-Real-IP`, then `CF-Connecting-IP`, else `unknown`. - Deploy behind trusted proxies and forward correct headers to populate `last_used_ip`. ## 6) UI Behavior (Database Mode) -- The “API Keys” tab appears between “NIP‑46” and “Recover”. -- If logged in as admin, the tab auto‑loads without prompting for `ADMIN_SECRET`. +- The “API Keys” tab appears between “NIP-46” and “Recover”. +- If logged in as admin, the tab auto-loads without prompting for `ADMIN_SECRET`. - Create form supports optional `label` and `userId`. - The full token is displayed once after creation with copy affordance. - Listing groups active and revoked with: `prefix`, `label`, timestamps, `last_used_ip`, and revoke action. @@ -100,6 +102,8 @@ Client IP attribution precedence: - Provision by setting `API_KEY` and restarting to rotate. - Not creatable/rotatable via HTTP; `/api/env` will reject attempts to set `API_KEY`. +- Sessions are disabled when `API_KEY` is set; login returns a warning and no `sessionId`. +- Authenticated userId is `api-user` (string). - Use API key headers in requests; the UI admin tab is disabled. ## 8) Operational Guidance @@ -183,16 +187,16 @@ See `scripts/api/README.md` for environment setup and usage details. ## 12) Testing Coverage (Summary) -- Positive paths: create → authenticate → list → revoke → double‑revoke 409; session‑admin list/create; headless key auth. -- Negative paths: admin route blocked in headless; invalid request bodies → 400; revoke unknown → 404. +- Positive paths: create -> authenticate -> list -> revoke -> double-revoke 409; session-admin list/create; headless key auth. +- Negative paths: admin route blocked in headless; invalid request bodies -> 400; revoke unknown -> 404. ## 13) Adoption & Migration -- Headless → Database Mode +- Headless -> Database Mode - Start DB mode with `ADMIN_SECRET` and complete onboarding. - Issue keys per integration; move clients to new tokens; revoke the old headless key. - Hybrid - - Database mode can still use Basic Auth and sessions; DB API‑key auth is available when at least one active key exists. + - Database mode can still use Basic Auth and sessions; DB API-key auth is available when at least one active key exists. The env `API_KEY` is ignored in DB mode. ## 14) Security Checklist (API Keys) diff --git a/llm/context/ENVIRONMENT_VARIABLES.md b/llm/context/ENVIRONMENT_VARIABLES.md index 661e6ba..88bc8cf 100644 --- a/llm/context/ENVIRONMENT_VARIABLES.md +++ b/llm/context/ENVIRONMENT_VARIABLES.md @@ -8,7 +8,7 @@ - The ALLOWED_ENV_KEYS whitelist - The PUBLIC_ENV_KEYS set -This secret is automatically generated and stored in a secure file with restricted permissions (0600). Any attempt to expose SESSION_SECRET via API would compromise the entire session security model. +When session auth is enabled, this secret is automatically generated and stored in a secure file with restricted permissions (0600). Any attempt to expose SESSION_SECRET via API would compromise the entire session security model. ## Overview @@ -17,13 +17,14 @@ Igloo Server operates in two distinct modes with different environment variable ## Mode Architecture ### Operation Modes -- **Database Mode** (`HEADLESS=false` or unset): Multi-user operation with encrypted credential storage in SQLite database -- **Headless Mode** (`HEADLESS=true`): Single-user operation with environment variable-based configuration +- **Database Mode** (`HEADLESS` unset or false): Multi-user operation with encrypted credential storage in SQLite. This is the default. +- **Headless Mode** (`HEADLESS=true|1|yes`): Single-user operation with environment variable-based configuration. DB-only routes (`/api/user`, `/api/admin`, `/api/nip46`) are disabled. ### Key Architectural Differences -1. **Credential Storage**: Plain text environment variables (Headless) vs encrypted database storage (Database) -2. **User Model**: Environment auth users vs database users with different API access patterns -3. **Security Model**: Basic env-based auth vs full user management with persistent salts +1. **Credential Storage**: Plain text environment variables (Headless) vs encrypted database storage (Database). Env creds can still boot the node in DB mode but are not persisted until saved via the UI/API. +2. **User Model**: Environment-auth users (API key/Basic) vs database users (numeric IDs) with different API access patterns. +3. **Session Persistence**: DB users get persisted sessions in SQLite; env-auth sessions are in-memory only. In headless mode with `API_KEY` set, sessions are disabled entirely. +4. **API Surface**: `/api/user`, `/api/admin`, and `/api/nip46` are available only in DB mode. ## Complete Environment Variables Reference @@ -31,85 +32,91 @@ Igloo Server operates in two distinct modes with different environment variable | Variable | Purpose | Headless Mode | Database Mode | Default | Notes | |----------|---------|---------------|---------------|---------|-------| -| `HEADLESS` | Controls operation mode | `true` | `false` | `false` | Core mode selector | +| `HEADLESS` | Controls operation mode | `true` | `false` | `false` | Truthy values: `true`, `1`, `yes` (case-insensitive) | ### Credential Storage | Variable | Purpose | Headless Mode | Database Mode | Default | Security Impact | |----------|---------|---------------|---------------|---------|-----------------| -| `GROUP_CRED` | FROSTR group credential | **REQUIRED** - stored as plain text | **OPTIONAL** - stored encrypted in DB | - | ⚠️ **CRITICAL**: Plain text vs encrypted | -| `SHARE_CRED` | FROSTR share credential | **REQUIRED** - stored as plain text | **OPTIONAL** - stored encrypted in DB | - | ⚠️ **CRITICAL**: Plain text vs encrypted | -| `ADMIN_SECRET` | Initial setup secret | **IGNORED** | **REQUIRED** on first setup only | - | Only enforced when DB uninitialized | +| `GROUP_CRED` | FROSTR group credential | **REQUIRED** - stored as plain text env | **OPTIONAL** - if set, boots node from env but is not persisted until saved | - | ⚠️ **CRITICAL**: Plain text env vs encrypted DB storage | +| `SHARE_CRED` | FROSTR share credential | **REQUIRED** - stored as plain text env | **OPTIONAL** - if set, boots node from env but is not persisted until saved | - | ⚠️ **CRITICAL**: Plain text env vs encrypted DB storage | +| `ADMIN_SECRET` | Initial setup secret | **IGNORED** | **REQUIRED** on first setup only | - | Enforced only when DB is uninitialized | ### Database Configuration | Variable | Purpose | Headless Mode | Database Mode | Default | Implementation | |----------|---------|---------------|---------------|---------|----------------| -| `DB_PATH` | Database file/directory location | **IGNORED** | Active | `./data` | Also controls SESSION_SECRET file location | +| `DB_PATH` | Database file/directory location | **IGNORED** | Active | `./data` | File or directory. Also controls `.session-secret` location | ### Network Configuration | Variable | Purpose | Both Modes Usage | Default | Source | |----------|---------|------------------|---------|--------| -| `HOST_NAME` | Server bind address | Identical behavior | `localhost` | `src/const.ts:25` | -| `HOST_PORT` | Server port | Identical behavior | `8002` | `src/const.ts:26` | -| `RELAYS` | Relay URLs (JSON array or CSV) | Identical parsing logic | `[]` | `src/const.ts:2-23` | +| `HOST_NAME` | Server bind address | Identical behavior | `localhost` | `src/const.ts` | +| `HOST_PORT` | Server port | Identical behavior | `8002` | `src/const.ts` | +| `RELAYS` | Relay URLs (JSON array or CSV) | Identical parsing logic | `[]` | `src/const.ts` | | `GROUP_NAME` | Display name for signing group | Identical behavior | - | Optional metadata | ### Authentication & Security | Variable | Purpose | Headless Mode | Database Mode | Default | Key Differences | |----------|---------|---------------|---------------|---------|-----------------| -| `AUTH_ENABLED` | Enable authentication | Same behavior | Same behavior | `true` | `src/routes/auth.ts:230` | -| `API_KEY` | API authentication key | Creates **env auth user** | Creates **env auth user** | - | Different user type implications | -| `BASIC_AUTH_USER` | Basic auth username | Creates **env auth user** | Creates **env auth user** | - | Different user type implications | -| `BASIC_AUTH_PASS` | Basic auth password | Creates **env auth user** | Creates **env auth user** | - | Different user type implications | -| `SESSION_SECRET` | Session signing key (⚠️ NEVER exposed via API) | Auto-generated in `data/.session-secret` | Auto-generated in `{DB_PATH}/.session-secret` | Auto-generated | Server-only, excluded from all API operations | -| `SESSION_TIMEOUT` | Session expiration (seconds) | Same behavior | Same behavior | `3600` | `src/routes/auth.ts:239` | +| `AUTH_ENABLED` | Enable authentication | Same behavior | Same behavior | `true` | Applies to all modes | +| `API_KEY` | API authentication key | **Used** (env API key) | **Ignored** (use DB API keys instead) | - | Headless only; disables sessions when set | +| `BASIC_AUTH_USER` | Basic auth username | Creates **env auth user** | Creates **env auth user** | - | Env-auth users are not DB users | +| `BASIC_AUTH_PASS` | Basic auth password | Creates **env auth user** | Creates **env auth user** | - | Env-auth users are not DB users | +| `SESSION_SECRET` | Session enablement secret (⚠️ NEVER exposed via API) | Auto-generated in `.session-secret` | Auto-generated in `.session-secret` | Auto-generated | Location is `{DB_PATH}` (file or dir) or `./data` | +| `SESSION_TIMEOUT` | Session expiration (seconds) | Same behavior | Same behavior | `3600` | Applies to DB + ephemeral sessions | +| `AUTH_DERIVED_KEY_TTL_MS` | Derived-key vault TTL (ms) | Same behavior | Same behavior | `120000` | Session derived key vault | +| `AUTH_DERIVED_KEY_MAX_READS` | Derived-key vault max reads | Same behavior | Same behavior | `100` | Session derived key vault | +| `AUTH_DERIVED_KEY_MAX_REHYDRATIONS` | Max rehydrate attempts | Same behavior | Same behavior | `3` | Session derived key cache | +| `VAULT_CLEANUP_INTERVAL_MS` | Vault cleanup interval (ms) | Same behavior | Same behavior | `120000` | Runs in-session cleanup | ### Rate Limiting | Variable | Purpose | Both Modes Usage | Default | Source | |----------|---------|------------------|---------|--------| -| `RATE_LIMIT_ENABLED` | Enable rate limiting | Identical behavior | `true` | `src/routes/auth.ts:242` | -| `RATE_LIMIT_WINDOW` | Rate limit window (seconds) | Identical behavior | `900` | `src/routes/auth.ts:243` | -| `RATE_LIMIT_MAX` | Max requests per window | Headless: `300`, Database: `600` | Mode-dependent | `src/routes/auth.ts:225,245` | +| `RATE_LIMIT_ENABLED` | Enable rate limiting | Identical behavior | `true` | `src/routes/auth.ts` | +| `RATE_LIMIT_WINDOW` | Rate limit window (seconds) | Identical behavior | `900` | `src/routes/auth.ts` | +| `RATE_LIMIT_MAX` | Max requests per window | Headless: `300`, Database: `600` | Mode-dependent | `src/routes/auth.ts` | +| `NIP46_SESSION_RATE_LIMIT_MAX` | NIP-46 session create max | Headless: `30`, Database: `120` | Mode-dependent | Applies to `/api/nip46/sessions` | +| `NIP46_SESSION_RATE_LIMIT_WINDOW` | NIP-46 session rate limit window (seconds) | Identical behavior | `3600` | Applies to `/api/nip46/sessions` | ### CORS Security | Variable | Purpose | Both Modes Usage | Default | Security Warning | |----------|---------|------------------|---------|------------------| -| `ALLOWED_ORIGINS` | CORS allowed origins (CSV) | Identical parsing | `*` | Warns in production if unset (`src/routes/utils.ts:269-271`) | +| `ALLOWED_ORIGINS` | CORS allowed origins (CSV) | Identical parsing | `*` | Warns in production if unset (`src/routes/utils.ts`) | ### Node Restart Configuration | Variable | Purpose | Both Modes Usage | Default | Range | Source | |----------|---------|------------------|---------|-------|--------| -| `NODE_RESTART_DELAY` | Initial restart delay (ms) | Identical behavior | `30000` | 1ms - 1 hour | `src/server.ts:31,38` | -| `NODE_MAX_RETRIES` | Max restart attempts | Identical behavior | `5` | 1 - 100 | `src/server.ts:32,39` | -| `NODE_BACKOFF_MULTIPLIER` | Exponential backoff multiplier | Identical behavior | `1.5` | 1.0 - 10.0 | `src/server.ts:33,40` | -| `NODE_MAX_RETRY_DELAY` | Max delay between retries (ms) | Identical behavior | `300000` | 1ms - 2 hours | `src/server.ts:34,41` | +| `NODE_RESTART_DELAY` | Initial restart delay (ms) | Identical behavior | `30000` | 1ms - 1 hour | `src/server.ts` | +| `NODE_MAX_RETRIES` | Max restart attempts | Identical behavior | `5` | 1 - 100 | `src/server.ts` | +| `NODE_BACKOFF_MULTIPLIER` | Exponential backoff multiplier | Identical behavior | `1.5` | 1.0 - 10.0 | `src/server.ts` | +| `NODE_MAX_RETRY_DELAY` | Max delay between retries (ms) | Identical behavior | `300000` | 1ms - 2 hours | `src/server.ts` | ### Operation Timeouts | Variable | Purpose | Both Modes Usage | Default | Range | Source | |----------|---------|------------------|---------|-------|--------| -| `FROSTR_SIGN_TIMEOUT` | Signing operation timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/routes/utils.ts:700-711`, `src/node/manager.ts:176` | -| `CONNECTIVITY_PING_TIMEOUT_MS` | Keepalive ping timeout (ms) | Identical behavior | `10000` | 1000ms - 120000ms | `src/node/manager.ts:101-111` | +| `FROSTR_SIGN_TIMEOUT` | Signing operation timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/routes/utils.ts`, `src/node/manager.ts` | +| `CONNECTIVITY_PING_TIMEOUT_MS` | Keepalive ping timeout (ms) | Identical behavior | `10000` | 1000ms - 120000ms | `src/node/manager.ts` | ### Error Circuit Breaker | Variable | Purpose | Both Modes Usage | Default | Range | Source | |----------|---------|------------------|---------|-------|--------| -| `ERROR_CIRCUIT_WINDOW_MS` | Time window for error counting | Identical behavior | `60000` | 1s - 1 hour | `src/server.ts:65,70` | -| `ERROR_CIRCUIT_THRESHOLD` | Errors before circuit trips | Identical behavior | `10` | 1 - 1000 | `src/server.ts:66,71` | -| `ERROR_CIRCUIT_EXIT_CODE` | Exit code when circuit trips | Identical behavior | `1` | 0 - 255 | `src/server.ts:67,72` | +| `ERROR_CIRCUIT_WINDOW_MS` | Time window for error counting | Identical behavior | `60000` | 1s - 1 hour | `src/server.ts` | +| `ERROR_CIRCUIT_THRESHOLD` | Errors before circuit trips | Identical behavior | `10` | 1 - 1000 | `src/server.ts` | +| `ERROR_CIRCUIT_EXIT_CODE` | Exit code when circuit trips | Identical behavior | `1` | 0 - 255 | `src/server.ts` | ### Proxy Configuration | Variable | Purpose | Both Modes Usage | Default | Source | |----------|---------|------------------|---------|--------| -| `TRUST_PROXY` | Trust proxy headers for client IP | Identical behavior | `false` | `src/routes/utils.ts:606` | +| `TRUST_PROXY` | Trust proxy headers for client IP | Identical behavior | `false` | `src/routes/utils.ts` | When `TRUST_PROXY=true`, the server trusts these headers (in order): `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`. Required for accurate rate limiting behind reverse proxies. @@ -157,8 +164,10 @@ process.env.SHARE_CRED = "bfshare1qqsqp..." ### 2. User Authentication Models **Environment Auth Users** (API Key/Basic Auth): -- **User ID Type**: `string` (e.g., "api-key-user", "basic-auth-user") +- **User ID Type**: `string` +- **Examples**: Headless API key -> `api-user`, DB API key -> `api-key:`, Basic Auth -> `` - **Salt Type**: Ephemeral session-specific salts +- **Session Storage**: In-memory only (not persisted in SQLite) - **API Access**: **CANNOT** access `/api/user/*` endpoints - **Purpose**: API access only, not credential management - **Security**: Prevents accidental data loss from ephemeral keys @@ -166,22 +175,34 @@ process.env.SHARE_CRED = "bfshare1qqsqp..." **Database Users** (Created via onboarding): - **User ID Type**: `number` (database primary key) - **Salt Type**: Persistent salts stored in database +- **Session Storage**: Persisted in SQLite `sessions` table plus in-memory metadata - **API Access**: **CAN** access all endpoints including `/api/user/*` - **Purpose**: Full web UI functionality with credential storage - **Security**: Consistent key derivation for credential encryption/decryption ### 3. Session Secret Storage -**File Location Logic** (`src/routes/auth.ts:31-40`): +`SESSION_SECRET` is a server-only enablement secret. Session IDs are random 32-byte hex values stored server-side; cookies are not signed. + +**File Location Logic** (`src/routes/auth.ts`): ```typescript function getSessionSecretDir(): string { const dbPath = process.env.DB_PATH; - if (!dbPath) { - return path.join(process.cwd(), 'data'); + if (!dbPath) return path.join(process.cwd(), 'data'); + + try { + const stats = statSync(dbPath); + return stats.isFile() ? path.dirname(dbPath) : dbPath; + } catch { + const normalized = path.normalize(dbPath); + if (normalized.endsWith(path.sep)) return normalized; + const base = path.basename(normalized); + const dbExtensions = ['.db', '.sqlite', '.sqlite3']; + if (dbExtensions.some(ext => base.toLowerCase().endsWith(ext))) { + return path.dirname(normalized); + } + return normalized; } - // Handle DB_PATH as file or directory - const isFile = dbPath.endsWith('.db') || path.extname(dbPath) !== ''; - return isFile ? path.dirname(dbPath) : dbPath; } ``` @@ -189,7 +210,7 @@ function getSessionSecretDir(): string { **CRITICAL SECURITY NOTE**: `SESSION_SECRET` must NEVER be exposed via any API endpoint. It is strictly server-only and excluded from all API read/write operations. -**Environment Variables API** (`src/routes/utils.ts:103-147`): +**Environment Variables API** (`src/routes/utils.ts`): ```typescript // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only @@ -247,7 +268,7 @@ const PUBLIC_ENV_KEYS = new Set([ ### 5. Startup Behavior -**Headless Mode** (`src/const.ts:39`): +**Headless Mode** (`src/const.ts`): ```typescript export const hasCredentials = () => GROUP_CRED !== undefined && SHARE_CRED !== undefined; @@ -255,15 +276,15 @@ export const hasCredentials = () => ``` **Database Mode**: -- Node starts when user logs in with valid credentials -- Node starts when credentials are saved to database -- `ADMIN_SECRET` required only when database is uninitialized +- If `GROUP_CRED`/`SHARE_CRED` are present, the node still boots from env at startup. +- When a DB user loads credentials (`GET /api/user/credentials`) or saves new ones, the node auto-starts (if not already running). +- `ADMIN_SECRET` is required only when the database is uninitialized (onboarding). ## Environment Variable Security Patterns ### 1. Validation and Defaults -Node restart configuration with validation (`src/server.ts:21-52`): +Node restart configuration with validation (`src/server.ts`): ```typescript const parseRestartConfig = () => { const initialRetryDelay = parseInt(process.env.NODE_RESTART_DELAY || '30000'); @@ -287,47 +308,40 @@ const parseRestartConfig = () => { ### 2. Auto-Generation Patterns -SESSION_SECRET auto-generation (`src/routes/auth.ts:44-136`): +SESSION_SECRET auto-generation (`src/routes/auth.ts`): ```typescript -function getOrCreateSessionSecret(): string { - // Ensure directory exists with secure permissions +function loadOrGenerateSessionSecret(): string | null { if (!existsSync(SESSION_SECRET_DIR)) { mkdirSync(SESSION_SECRET_DIR, { recursive: true, mode: 0o700 }); - } else { - chmodSync(SESSION_SECRET_DIR, 0o700); } - - // Check if secret already exists + chmodSync(SESSION_SECRET_DIR, 0o700); + if (existsSync(SESSION_SECRET_FILE)) { const secret = readFileSync(SESSION_SECRET_FILE, 'utf-8').trim(); - if (secret && secret.length >= 32) { - return secret; - } + if (/^[0-9a-f]{64}$/i.test(secret)) return secret; } - - // Generate new secret (32 bytes = 64 hex characters) + const newSecret = randomBytes(32).toString('hex'); - - // Atomically write the new secret - const tempFilePath = path.join(SESSION_SECRET_DIR, '.session-secret.tmp'); - - // Write to temp file with secure permissions - writeFileSync(tempFilePath, newSecret, { encoding: 'utf8', mode: 0o600 }); - - // Atomically rename to final location + const tempFileName = `.session-secret.tmp.${process.pid}.${randomBytes(8).toString('hex')}`; + const tempFilePath = path.join(SESSION_SECRET_DIR, tempFileName); + + const fd = openSync(tempFilePath, 'wx', 0o600); + writeSync(fd, newSecret, 0, 'utf8'); + fsyncSync(fd); renameSync(tempFilePath, SESSION_SECRET_FILE); - - // Enforce file permissions chmodSync(SESSION_SECRET_FILE, 0o600); - + process.env.SESSION_SECRET = newSecret; return newSecret; } ``` +Notes: +- On Windows, chmod and directory fsync are best-effort; warnings are logged. +- In production, a missing/invalid secret that cannot be generated will terminate the process. ### 3. CORS Security Warnings -Production security warning (`src/routes/utils.ts:269-271`): +Production security warning (`src/routes/utils.ts`): ```typescript if (!allowedOriginsEnv) { headers['Access-Control-Allow-Origin'] = '*'; @@ -339,7 +353,7 @@ if (!allowedOriginsEnv) { ## Migration Patterns -### Headless → Database Mode Migration +### Headless -> Database Mode Migration 1. **Preparation**: ```bash @@ -364,11 +378,11 @@ if (!allowedOriginsEnv) { - Credentials move to encrypted database storage 4. **Security Upgrade**: - - Plain text env credentials → AES-256-GCM encrypted storage - - Environment auth users → Full database user accounts - - Session-specific salts → Persistent salts for consistent key derivation + - Plain text env credentials -> AES-256-GCM encrypted storage + - Environment auth users -> Full database user accounts + - Session-specific salts -> Persistent salts for consistent key derivation -### Database → Headless Mode Migration +### Database -> Headless Mode Migration 1. **Credential Export** (manual process): - Login to web UI @@ -388,9 +402,9 @@ if (!allowedOriginsEnv) { ``` 4. **Security Downgrade**: - - Encrypted database storage → Plain text env credentials - - Full user accounts → Environment auth users - - Persistent salts → Ephemeral session-specific salts + - Encrypted database storage -> Plain text env credentials + - Full user accounts -> Environment auth users + - Persistent salts -> Ephemeral session-specific salts ## Development Guidelines @@ -478,20 +492,20 @@ cp .env.database .env # Test database ### Key Implementation Files -- **Environment Constants**: `src/const.ts:1-91` -- **Authentication Config**: `src/routes/auth.ts:228-246` -- **Environment Utils**: `src/routes/utils.ts:101-180` -- **Database Config**: `src/db/database.ts:11-14` -- **Restart Config**: `src/server.ts:30-61` -- **Error Circuit Config**: `src/server.ts:64-88` -- **CORS Security**: `src/routes/utils.ts:447-557` -- **Proxy/IP Detection**: `src/routes/utils.ts:599-624` +- **Environment Constants**: `src/const.ts` +- **Authentication Config**: `src/routes/auth.ts` +- **Environment Utils**: `src/routes/utils.ts` +- **Database Config**: `src/db/database.ts` +- **Restart Config**: `src/server.ts` +- **Error Circuit Config**: `src/server.ts` +- **CORS Security**: `src/routes/utils.ts` +- **Proxy/IP Detection**: `src/routes/utils.ts` ### Environment Variable Whitelisting -- **Write/Validation Whitelist**: `src/routes/utils.ts:103-124` (`ALLOWED_ENV_KEYS`) -- **Public Read Whitelist**: `src/routes/utils.ts:128-147` (`PUBLIC_ENV_KEYS`) -- **Forbidden Keys**: `src/routes/utils.ts:150` (`FORBIDDEN_ENV_KEYS`) -- **Key Validation**: `src/routes/utils.ts:173-180` (`validateEnvKeys`) +- **Write/Validation Whitelist**: `src/routes/utils.ts` (`ALLOWED_ENV_KEYS`) +- **Public Read Whitelist**: `src/routes/utils.ts` (`PUBLIC_ENV_KEYS`) +- **Forbidden Keys**: `src/routes/utils.ts` (`FORBIDDEN_ENV_KEYS`) +- **Key Validation**: `src/routes/utils.ts` (`validateEnvKeys`) This reference serves as the definitive guide for understanding Igloo Server's dual-mode architecture and environment variable system. diff --git a/llm/context/NIP46_IMPLEMENTATION.md b/llm/context/NIP46_IMPLEMENTATION.md index 8637471..fc86a32 100644 --- a/llm/context/NIP46_IMPLEMENTATION.md +++ b/llm/context/NIP46_IMPLEMENTATION.md @@ -12,6 +12,10 @@ Igloo Server implements NIP-46 (Nostr Connect) as a remote signer that allows No - **Database**: SQLite tables store sessions, policies, requests, and transport keys - **Frontend UI**: React components for session management, request approval, and relay configuration +**Availability** +- NIP-46 routes are enabled only in **Database Mode**. Headless mode returns 404 for `/api/nip46/*`. +- Requests require an authenticated **DB user** (numeric user id). Env-auth users (API key/Basic) cannot access NIP-46. + ## 2. Architecture ### Dual-Key Model @@ -57,6 +61,8 @@ CREATE TABLE nip46_sessions ( UNIQUE(user_id, client_pubkey) ); ``` +Notes: +- `revoked` remains in the schema for compatibility, but revoked sessions are deleted and excluded from listings. ### nip46_requests @@ -151,25 +157,25 @@ Base path: `/api/nip46/` | Method | Path | Description | |--------|------|-------------| -| `GET` | `/sessions` | List active/pending sessions | -| `POST` | `/sessions` | Create/update session manually | +| `GET` | `/sessions` | List active/pending sessions (`?history=true` adds recent grants) | +| `POST` | `/sessions` | Create/update session manually (rate-limited) | | `PUT` | `/sessions/:pubkey/policy` | Update session permissions | -| `PUT` | `/sessions/:pubkey/status` | Change session status | +| `PUT` | `/sessions/:pubkey/status` | Change session status (`revoked` deletes) | | `DELETE` | `/sessions/:pubkey` | Delete session | ### Requests | Method | Path | Description | |--------|------|-------------| -| `GET` | `/requests` | List requests (filter by status) | -| `POST` | `/requests` | Approve/deny/complete request | -| `DELETE` | `/requests` | Delete request | +| `GET` | `/requests` | List requests (`?status=pending,approved&limit=100`, max 500) | +| `POST` | `/requests` | Update request status (`action=approve|deny|fail|complete`); optional policy patch | +| `DELETE` | `/requests` | Delete request (body includes `id`) | ### History | Method | Path | Description | |--------|------|-------------| -| `GET` | `/history` | Sessions with recent activity stats | +| `GET` | `/history` | Sessions with recent grant history (`recent_kinds`, `recent_methods`) | ## 5. Request Processing Flow @@ -178,7 +184,7 @@ Base path: `/api/nip46/` 1. User scans/pastes `nostrconnect://pubkey?relay=...&secret=...` URI 2. Frontend calls `POST /api/nip46/connect` with the URI 3. Server decodes invite, extracts client pubkey, relays, requested permissions -4. Server creates session with status `pending` or `active` +4. Server creates a session with status `pending` (marked `active` on connect or activity) 5. Server subscribes to client's relays via `SignerAgent` 6. Server sends connect acknowledgment back to client @@ -222,7 +228,8 @@ interface Nip46Policy { ### Default Policy -New sessions start with these defaults: +- **Stored session policy** is empty unless provided (via `perms` in the connect URI or `/api/nip46/sessions`). Empty policy means **no auto-approval**. +- **SignerAgent base policy** (for the transport layer) is initialized as: ```typescript { @@ -247,6 +254,8 @@ A request is auto-approved when: ### Policy from Connect URI +The server prefers an explicit `invite.policy` (from the decoded connect string). If none is provided, it parses the `perms` query string. + The `nostrconnect://` URI can include a `perms` parameter: ``` @@ -254,9 +263,9 @@ nostrconnect://pubkey?relay=wss://...&perms=sign_event:1,sign_event:4,nip44_encr ``` This is parsed into policy: -- `sign_event:1` → `kinds["1"] = true` -- `sign_event:4` → `kinds["4"] = true` -- `nip44_encrypt` → `methods["nip44_encrypt"] = true` +- `sign_event:1` -> `kinds["1"] = true` +- `sign_event:4` -> `kinds["4"] = true` +- `nip44_encrypt` -> `methods["nip44_encrypt"] = true` ## 7. Service Lifecycle @@ -273,7 +282,7 @@ initNip46Service({ ### Startup -1. Service waits for active user (`setActiveUser(userId)`) +1. Service waits for active user (`setActiveUser(userId)`), typically set when a DB user loads credentials or calls NIP-46 endpoints 2. Loads or generates transport key from database 3. Loads relay list (default: `['wss://relay.primal.net']`) 4. Creates `SimpleSigner` with transport key @@ -357,8 +366,8 @@ await nip46Service.stop() - **Threshold signing**: Identity operations use FROSTR threshold signatures; full private key never exists - **Policy enforcement**: All requests go through policy check before execution - **Request queue**: Requests not auto-approved require manual UI approval -- **Session revocation**: Revoked sessions are deleted from database -- **Rate limiting**: Session creation limited to 120/hour (30 in headless mode) +- **Session revocation**: Revoked sessions are deleted from database (status not persisted) +- **Rate limiting**: Session creation limited by `NIP46_SESSION_RATE_LIMIT_MAX`/`NIP46_SESSION_RATE_LIMIT_WINDOW` (defaults: 120 per 3600s) - **Data size limits**: JSON fields limited to 50KB to prevent DoS ## 11. Configuration @@ -368,6 +377,8 @@ await nip46Service.stop() | Variable | Default | Description | |----------|---------|-------------| | `FROSTR_SIGN_TIMEOUT` | `30000` | Timeout for signing operations (ms) | +| `NIP46_SESSION_RATE_LIMIT_MAX` | `120` | Max session creates per window | +| `NIP46_SESSION_RATE_LIMIT_WINDOW` | `3600` | Rate limit window in seconds | ### Default Relay @@ -384,11 +395,11 @@ Each user can configure up to 32 relays. | Function | Location | Purpose | |----------|----------|---------| -| `Nip46Service` | `src/nip46/service.ts:175` | Main service class | -| `handleSocketRequest` | `src/nip46/service.ts:433` | Process incoming requests | -| `processApprovedRequest` | `src/nip46/service.ts:618` | Execute approved requests | -| `handleSignEvent` | `src/nip46/service.ts:826` | Sign event via FROSTR | -| `shouldAutoApprove` | `src/nip46/service.ts:772` | Policy check logic | -| `upsertSession` | `src/db/nip46.ts:399` | Create/update session | -| `createNip46Request` | `src/db/nip46.ts:306` | Queue new request | +| `Nip46Service` | `src/nip46/service.ts` | Main service class | +| `handleSocketRequest` | `src/nip46/service.ts` | Process incoming requests | +| `processApprovedRequest` | `src/nip46/service.ts` | Execute approved requests | +| `handleSignEvent` | `src/nip46/service.ts` | Sign event via FROSTR | +| `shouldAutoApprove` | `src/nip46/service.ts` | Policy check logic | +| `upsertSession` | `src/db/nip46.ts` | Create/update session | +| `createNip46Request` | `src/db/nip46.ts` | Queue new request | | `deriveSharedSecret` | `src/routes/crypto-utils.ts` | ECDH for NIP-44 | diff --git a/llm/implementation/auth-implementation.md b/llm/implementation/auth-implementation.md new file mode 100644 index 0000000..bc50934 --- /dev/null +++ b/llm/implementation/auth-implementation.md @@ -0,0 +1,140 @@ +# Authentication and Session Implementation (Database + Headless) + +Last verified: 2026-02-05 + +## Scope +This document captures how authentication, sessions, derived key handling, and rate limiting are implemented in Igloo Server, including the design choices for database mode and headless deployments. + +## Architecture Summary +- Authentication is centralized in `src/routes/auth.ts` and wired through the unified router in `src/routes/index.ts`. +- Sensitive request state is wrapped via `src/routes/auth-factory.ts` using a WeakMap-backed `RequestAuth` with secure getters. +- Sessions are persisted minimally in SQLite in database mode and mirrored in an in-memory metadata store for derived key features. +- Derived keys are stored only in-memory in a short-lived vault with bounded reads and explicit zeroization. +- Rate limiting uses a persistent SQLite-backed limiter with an in-memory fallback. + +## Modes and Auth Methods +Database mode (HEADLESS=false): +- Primary auth methods are database API keys, Basic Auth (optional), and user sessions. +- DB API key auth is enabled only when at least one active key exists in `api_keys`. +- Sessions are backed by SQLite for persistence across restarts. +- Admin-only mutations require ADMIN_SECRET or an admin-role user session. +- The env `API_KEY` is ignored in database mode. + +Headless mode (HEADLESS=true): +- Primary auth methods are env `API_KEY` and Basic Auth; sessions are optional but disabled when `API_KEY` is set. +- Env mutations require API key or Basic Auth; session auth alone is not sufficient for writes. + +## ADMIN_SECRET and Onboarding +- ADMIN_SECRET is required only for initial database setup when the DB is uninitialized; the server fails fast if missing. +- The onboarding flow validates ADMIN_SECRET unless `SKIP_ADMIN_SECRET_VALIDATION=true` and ADMIN_SECRET is set. +- In CI/test (or when `AUTO_ADMIN_SECRET=true`), a fallback admin secret is auto-generated for non-production runs. +- Onboarding endpoints are rate-limited and enforce a uniform response delay to reduce timing leakage. + +Files: +- `src/server.ts` enforces ADMIN_SECRET on first-run in DB mode. +- `src/routes/onboarding.ts` implements validate/setup with rate limiting and uniform delay. +- `src/const.ts` defines ADMIN_SECRET and SKIP_ADMIN_SECRET_VALIDATION semantics. + +## Session Secret Persistence +- SESSION_SECRET is required for session auth and must be 64 hex chars (32 bytes). +- If not provided, Igloo generates and persists it at `/.session-secret` (directory inferred from `DB_PATH`). +- The generator uses atomic write+rename and enforces permissions: dir 0700, file 0600 (best-effort on Windows). +- In headless mode with `API_KEY` configured, session management is disabled to avoid unnecessary file I/O. +- Session IDs are random 32-byte hex values stored server-side; cookies are not signed. +- If SESSION_SECRET cannot be loaded/generated in non-production, sessions are disabled and login returns a warning with no `sessionId`. + +Files: +- `src/routes/auth.ts` (loadOrGenerateSessionSecret, validateSessionSecret) + +## Session Storage and Lifecycle +- Database sessions persist only minimal state: `id`, `user_id`, `ip_address`, `created_at`, `last_access`, and only for numeric DB users. +- An in-memory `sessionStore` keeps ephemeral metadata (rehydration counters, hasPassword, per-session salts). +- Session TTL is enforced via `SESSION_TIMEOUT` (default 3600s) for both DB and ephemeral sessions. +- Cleanup runs every 10 minutes and deletes expired DB sessions via `cleanupExpiredSessionsDB`. + +Files: +- `src/db/migrations/20251009_0005_add_sessions_table.sql` +- `src/db/database.ts` (session CRUD + cleanup) +- `src/routes/auth.ts` (createSession, authenticateSession, cleanupExpiredSessions) + +## WebSocket Event Stream Auth (`/api/events`) +- The event stream uses the same auth pipeline as HTTP; if `AUTH_ENABLED=true`, an auth check happens during the WebSocket upgrade. +- Supported session inputs: + - `X-Session-ID` header or `session` cookie. + - Query parameter `sessionId` (mapped to `X-Session-ID` for compatibility). +- Supported API key inputs: + - `X-API-Key` header or `Authorization: Bearer `. + - Query parameter `apiKey` (mapped to `X-API-Key` for compatibility). +- Optional `Sec-WebSocket-Protocol` hints for non-browser clients: + - `apikey.` or `api-key.` sets `X-API-Key`. + - `bearer.` sets `Authorization: Bearer `. + - `session.` sets `X-Session-ID`. +- The first offered `Sec-WebSocket-Protocol` value is echoed back in the upgrade response (per RFC6455), even though it is only used as a hint. + +Files: +- `src/server.ts` (WebSocket upgrade + auth hint parsing) + +## Derived Key Vault (Ephemeral) +- Password-based derived keys are never stored in the DB or on the session object. +- Derived keys are stored only in-memory in two places: + - `sessionDerivedKeyCache`: long-lived for session duration. + - `derivedKeyVault`: short-lived vault with TTL and bounded reads. +- Vault defaults and bounds: + - `AUTH_DERIVED_KEY_TTL_MS` default 120000, clamped to 10s..10m. + - `AUTH_DERIVED_KEY_MAX_READS` default 100, clamped to 1..1000. + - `AUTH_DERIVED_KEY_MAX_REHYDRATIONS` default 3, clamped to 0..100. + - Vault cleanup interval `VAULT_CLEANUP_INTERVAL_MS` default 120000, clamped to 30s..10m. +- Keys are copied on insert and zeroized on removal; zeroization happens on logout and session cleanup. +- Rehydration is allowed only while the session exists and within a limited quota. + +Files: +- `src/routes/auth.ts` (vault + cache) +- `src/routes/auth-factory.ts` (secure getters, lazy vault retrieval) +- `src/util/zeroize.ts` (zeroization helpers) + +## Password vs Derived Key Decisions +- Database users: derive the key from the user's stored salt (stable across sessions). +- Non-database users: derive a key using a session-specific salt and do not allow credential storage. +- This prevents env-auth users from persisting encrypted credentials they cannot rehydrate later. + +Files: +- `src/routes/auth.ts` (createSession key derivation) +- `src/routes/user.ts` (credential access requires password or derived key) + +## RequestAuth Hardening +- `RequestAuth` stores secrets in a WeakMap to avoid accidental JSON/spread leakage. +- `getDerivedKey()` retrieves from vault once and refreshes TTL/read counters, then caches in-memory for the request. +- `destroySecrets()` zeroizes derived keys and unregisters finalizers. + +Files: +- `src/routes/auth-factory.ts` + +## Rate Limiting +- Global rate limiting is enforced in `authenticate()` and onboarding endpoints. +- `PersistentRateLimiter` uses SQLite for durable counters and falls back to in-memory if DB is unavailable. +- Defaults: + - `RATE_LIMIT_WINDOW` 900s, `RATE_LIMIT_MAX` 300 (headless) or 600 (database). + - `RATE_LIMIT_ENV_WRITE_WINDOW` and `RATE_LIMIT_ENV_WRITE_MAX` override env write limits. + +Files: +- `src/utils/rate-limiter.ts` +- `src/routes/auth.ts` (checkRateLimit) +- `src/routes/onboarding.ts` (per-IP rate limiting) +- `src/routes/env.ts` (env write throttling) + +## Privileged Routes and Admin Control +- `/api/admin/*` uses ADMIN_SECRET or an admin-role session; auth is optional and validated inside the admin handler. +- `/api/env` writes require an authenticated session in DB mode plus ADMIN_SECRET or admin role. +- Headless `/api/env` writes require API key or Basic Auth (sessions are not sufficient). +- `/api/env/admin-secret` requires admin role and explicit confirmation in the request body to reveal the secret. + +Files: +- `src/routes/index.ts` (routing and auth wiring) +- `src/routes/env.ts` (privileged env controls) +- `src/routes/admin.ts` (admin routes) + +## Implementation Notes and Rationale +- Sessions persist only what is required for authorization; secrets stay in memory only. +- Derived keys are short-lived and zeroized to reduce exposure if memory is compromised. +- Onboarding uses uniform delay and rate limiting to reduce brute-force and timing leakage on ADMIN_SECRET. +- Headless mode treats API keys as the primary auth and avoids storing persistent session state unless explicitly configured. diff --git a/llm/implementation/credential-storage-implementation.md b/llm/implementation/credential-storage-implementation.md new file mode 100644 index 0000000..a3ab1f0 --- /dev/null +++ b/llm/implementation/credential-storage-implementation.md @@ -0,0 +1,80 @@ +# Credential Storage and Encryption (Database Mode) + +Last verified: 2026-02-05 + +## Scope +This document captures how Igloo Server stores, encrypts, and retrieves user credentials in database mode, including key derivation, crypto configuration, and data-model choices. + +## Key Files +- `src/db/database.ts` (schema, encryption, credential CRUD) +- `src/config/crypto.ts` (PBKDF2, AES-GCM, salt, Argon2id config) +- `src/routes/user.ts` (credential API usage and update flows) +- `src/routes/auth.ts` (derived-key generation for sessions) + +## Data Model +- Credentials are stored in SQLite in the `users` table. +- Encrypted fields: +- `group_cred_encrypted` and `share_cred_encrypted` store ciphertext (AES-256-GCM, base64). +- Plaintext fields: +- `relays` and `group_name` are stored as plain JSON/string (not encrypted). +- `salt` is stored in plaintext and used only for PBKDF2 key derivation. +- `password_hash` stores an Argon2id hash with embedded salt for authentication. + +## Crypto Configuration +Defined in `src/config/crypto.ts`: +- PBKDF2: `sha256`, 200000 iterations, 32-byte key length. +- AES-GCM: 256-bit key, 12-byte IV, 16-byte tag. +- Salt length: 32 bytes (256 bits). +- Password hashing: Argon2id via `Bun.password` with 64MB memory cost and 3 iterations. + +## Dual-Salt Design +- Authentication uses Argon2id hashes that include their own salt. +- Encryption uses a separate per-user salt stored in `users.salt`. +- This separation is intentional so authentication and encryption salts are not shared. + +## Key Derivation +- For password-based operations, `deriveKey(password, user.salt)` uses PBKDF2 and returns 32 bytes. +- The derived key is hex-encoded and used directly as the AES-256-GCM key. +- Derived keys can also be passed directly as 32-byte binary or 64-char hex for session-based flows. + +## Ciphertext Format +- AES-256-GCM encrypts with a random 12-byte IV per operation. +- Stored format is `base64(iv || authTag || ciphertext)`. +- Decryption reverses this layout and validates key length before use. + +## Credential Write Path +Function: `updateUserCredentials(userId, credentials, passwordOrKey, isDerivedKey)` +- Only `group_cred` and `share_cred` require encryption. +- If neither encrypted field is updated, no key is required and relays/group_name can be updated without a password. +- If `isDerivedKey=true`, the key must be 32 bytes (binary) or 64 hex chars. +- If `isDerivedKey=false`, a PBKDF2 key is derived from the plaintext password and `users.salt`. +- Encrypted fields are replaced atomically in a single `UPDATE`. + +## Credential Read Path +Function: `getUserCredentials(userId, passwordOrKey, isDerivedKey)` +- Requires either password or derived key to decrypt encrypted fields. +- Uses the same key format validation as the write path. +- Returns `{ group_cred, share_cred, relays, group_name }` or `null` on failure. +- Decryption errors are logged and treated as a failed read. + +## Derived Key Integration +- In session-based flows, a derived key may be generated during login and stored only in memory. +- `updateUserCredentials` and `getUserCredentials` accept derived keys to avoid re-deriving per request. +- Derived keys are never stored in the database. + +## Credential Presence Checks +- `userHasStoredCredentials` and `anyUserHasStoredCredentials` require both encrypted fields to be non-null. +- Admin user listing includes `hasCredentials` derived from the same check. + +## Deletion Semantics +- `deleteUserCredentials` clears encrypted credentials and related plaintext fields. +- The operation also nulls `relays`, `peer_policies`, and `group_name` to avoid stale state. + +## Admin Guard Note +- `deleteUserSafely` considers a user an admin if both encrypted credentials are present. +- This guard prevents deletion of the last credential-bearing user. + +## Implementation Notes +- Encrypted credential updates are fail-closed: missing or invalid key formats cause a write to fail. +- Password hashing and credential encryption are intentionally separate concerns with separate salts. +- Relays are stored plaintext and can be updated without a key when no encrypted fields are touched. diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md new file mode 100644 index 0000000..f4e19ae --- /dev/null +++ b/llm/implementation/node-lifecycle-implementation.md @@ -0,0 +1,91 @@ +# Bifrost Node Lifecycle and Credential Application + +Last verified: 2026-02-05 + +## Scope +This document captures how the server creates, replaces, and monitors the Bifrost node, and how credentials, relays, and peer policies are applied from env and database sources. + +## Key Files +- `src/server.ts` (startup, restart logic, `updateNode`) +- `src/node/manager.ts` (node creation, monitoring, echo, event listeners) +- `src/utils/node-lock.ts` (serialized node updates) +- `src/routes/env.ts` (env-based credential updates) +- `src/routes/user.ts` (DB credential updates) +- `src/routes/peers.ts` (peer policy updates + persistence) +- `src/node/peer-policy-store.ts` (fallback policy persistence) +- `src/util/peer-policy.ts` (sanitize + merge policy inputs) + +## Credential Sources and Snapshots +- `ActiveNodeCredentials` is the canonical in-memory snapshot used for restarts: group, share, relaysEnv, peerPoliciesRaw, and source (`env` or `dynamic`). +- `buildEnvCredentialSnapshot()` pulls from `GROUP_CRED`, `SHARE_CRED`, `RELAYS`, and `PEER_POLICIES` when present. +- `activeCredentials` is updated whenever a node is started or replaced, so restart logic can reuse the last-known good credential set even if env changes later. + +## Startup Behavior +- If `GROUP_CRED` and `SHARE_CRED` are present at boot, the server creates a node immediately with `createNodeWithCredentials()`. +- If no credentials exist, the server starts without a node and waits for credentials via UI/API. +- In headless mode, if `SKIP_STARTUP_ECHO=false`, the server sends a self-echo and a broadcast share echo as non-blocking connectivity signals after initial node creation. + +## Node Updates and Locking +- All node creation/replacement operations are serialized with `executeUnderNodeLock()` to avoid race conditions. +- Re-entrant lock acquisition is detected using `AsyncLocalStorage` and treated as an error to prevent deadlocks. +- `updateNode()` is synchronous and is the single cleanup boundary. It calls `cleanupBifrostNode()`, resets monitoring, attaches listeners, updates `activeCredentials`, and clears restart blocked state. + +## Credential Update Flows +Env/admin updates (`/api/env`, `/api/env/shares`): +- Env writes are validated and persisted to `.env`. +- Credential or relay changes trigger node recreation under the lock via `createNodeWithCredentials()`. +- The context `updateNode()` swaps the node atomically and reattaches monitoring. +- Self-echo and broadcast echo are fired after credential updates to surface relay issues without blocking the update. +- Deletions (`/api/env/delete`) call `cleanupNodeSynchronized()` which runs `updateNode(null)` under lock. + +DB user updates (`/api/user/credentials`): +- GET: if stored credentials exist and the node is missing, the server auto-starts a node under the lock. +- POST/PUT: credentials are saved, then self-echo + broadcast echo are fired (non-blocking). The node is started if missing; existing nodes are not force-restarted here. +- DELETE: deletes credentials and cleans up the node under the lock. +- Relay-only updates (`/api/user/relays`) update the DB but do not restart the node. The running node keeps its current relay set until a restart occurs. + +## Peer Policy Persistence and Application +- `PEER_POLICIES` env is JSON-parsed and normalized via igloo-core's `normalizeNodePolicies`. +- `/api/peers/*` mutates policies via `setNodePolicies()` and persists them. +- DB mode persistence uses `peer_policies` on the user record. +- Headless or unknown user persistence uses `data/peer-policies.json` fallback store. +- On node creation, fallback policies are merged into env policies using `mergePolicyInputs()`. Runtime overrides take precedence and are tagged with `source: 'runtime'`. + +## Node Creation Details (`createNodeWithCredentials`) +- Relays are parsed with `getValidRelays(relaysEnv)`. +- Relay probing (kind 20004) can be skipped with `SKIP_RELAY_PROBE=true`. +- Relay probing can be deferred with `DEFER_RELAY_PROBE=true`, which runs in the background after startup. +- Connection strategy uses up to 5 attempts with `createConnectedNode()` (30s timeout, autoReconnect on). +- Progressive backoff is applied between attempts, with a final fallback to `createAndConnectNode()`. +- A SimplePool `subscribeMany` normalization patch is applied for single-filter arrays to avoid inconsistent nostr-tools behavior. +- The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. +- The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. +- `NODE_PUBLISH_METRICS=false` disables instrumentation. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false` (or `RELAY_ALLOW_BENIGN_SWALLOW`) forces publish errors to surface. +- Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. + +## Monitoring and Recovery +- `setupNodeEventListeners()` wires Bifrost events, peer status tracking, and connectivity monitoring. +- Monitoring runs every 60s and checks node validity and client presence. +- Monitoring enforces idle thresholds and keepalive ping when supported. +- Monitoring evaluates relay connection status and reconnection attempts. +- After 3 consecutive failures the monitor invokes a recreate callback. Backoff in the monitor doubles up to 1 hour, with `MAX_RECREATION_ATTEMPTS=5` before requiring manual intervention. +- Server-level restarts use a separate backoff loop with env tuning. +- `NODE_RESTART_DELAY` defaults to 30s. +- `NODE_MAX_RETRIES` defaults to 5. +- `NODE_BACKOFF_MULTIPLIER` defaults to 1.5. +- `NODE_MAX_RETRY_DELAY` defaults to 300s. +- Restart uses `activeCredentials` if set, otherwise the env snapshot. If no credentials exist, restarts are blocked and logged until credentials return. + +## Echo and Connectivity Signals +- `sendSelfEcho()` uses igloo-core `sendEcho()` with bounded timeouts and logs soft timeouts instead of failing the flow. +- `broadcastShareEcho()` spins up a temporary node to publish `/echo/req` to the node pubkey, then cleans up the node immediately. Relays are resolved from explicit input, env relays, group relays, and `DEFAULT_ECHO_RELAYS`. + +## Event Stream and Peer Status +- Message handlers map sign/ECDH/ping tags to user-facing events and update `peerStatuses`. +- `peerStatuses` is FIFO-evicted at `MAX_PEER_STATUS_ENTRIES` to cap memory usage. +- The event stream suppresses noisy aggregation messages to keep UI logs readable. + +## Shutdown +- Graceful shutdown calls `cleanupMonitoring()` and `clearCleanupTimers()`, then closes NIP-46 services and the database. +- Node cleanup is always performed via `cleanupBifrostNode()` through `updateNode()` or restart handlers. diff --git a/llm/implementation/session-management.md b/llm/implementation/session-management.md new file mode 100644 index 0000000..1f51813 --- /dev/null +++ b/llm/implementation/session-management.md @@ -0,0 +1,7 @@ +# Session Management (Auth) + +This content has been unified into `llm/implementation/auth-implementation.md` to avoid drift. + +See: +- `llm/implementation/auth-implementation.md` for full session lifecycle, derived key vault, and WebSocket auth details. +- `llm/context/NIP46_IMPLEMENTATION.md` for NIP-46 client session behavior (separate from HTTP/UI sessions). diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md new file mode 100644 index 0000000..ca9e96c --- /dev/null +++ b/llm/implementation/umbrel-implementation.md @@ -0,0 +1,77 @@ +# Umbrel Implementation (Released) + +Last verified: 2026-02-05 + +## Scope +This document captures the working, released Umbrel packaging for Igloo Server. It reflects both the packaging inside this repo and the live Umbrel community store repo used for distribution. + +## Release Artifacts +- Umbrel image is built from `packages/umbrel/igloo/Dockerfile` and published by `.github/workflows/release.yml`. +- Published tags: + - `ghcr.io/frostr-org/igloo-server:umbrel-latest` + - `ghcr.io/frostr-org/igloo-server:umbrel-` +- The Umbrel dev workflow `.github/workflows/umbrel-dev.yml` builds and smoke-tests a local image only; it does not push. + +## Umbrel Community Store Repo (Igloo Server Store) +Local path: `/Users/plebdev/Desktop/code/igloo-server-store` +Upstream repo: `https://github.com/frostr-org/igloo-server-store` + +Key files and current state: +- `umbrel-app-store.yml` + - Store id: `igloo` + - Store name: `Igloo Server Store` +- `igloo-server/umbrel-app.yml` + - `version: 1.1.0` + - `port: 8002`, `tor: true` + - Assets are remote URLs (icon + gallery screenshots). + - Description calls out database mode defaults and admin secret auto-provisioning. +- `igloo-server/docker-compose.yml` + - Image pinned to a digest (current): + - `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:537a21c960402f12e2157432ca91573d6155a1c17ef88a4a07e42bd839867d2f` + - `APP_DATA_DIR` is mounted to `/app/data`. + - `ALLOWED_ORIGINS` default includes `@self` plus `umbrel.local` variants. + - App proxy is defined in `umbrel-app.yml` via an `app_proxy` block that points to the Igloo service/port. There is no `PROXY_AUTH_WHITELIST` env. + +## Umbrel Image Implementation +Source: `packages/umbrel/igloo/Dockerfile` + +Build and runtime details: +- Multi-stage build uses `oven/bun:1.1.30` and runs `bun run build` to compile frontend assets. +- Runtime stage installs `tini` and `curl`, sets `HOST_NAME=0.0.0.0`, `HOST_PORT=8002`. +- Runs as non-root user `igloo` (UID/GID 1000) and exposes port `8002`. +- Declares volume `VOLUME ["/app/data"]`. +- Entrypoint is `scripts/umbrel-entrypoint.sh`. + +Entrypoint behavior (`scripts/umbrel-entrypoint.sh`): +- Ensures `/app/data` exists. +- Attempts to `chown -R 1000:1000 /app/data` and `chmod 700`. +- Logs a warning if ownership cannot be changed (for example, if the host volume is owned by root). + +## Runtime Configuration (Umbrel Defaults) +These values are set in the store compose and expected by the UI flow: +- `ADMIN_SECRET` comes from Umbrel `APP_PASSWORD`. +- `SKIP_ADMIN_SECRET_VALIDATION=true` so onboarding skips the secret entry screen. +- `AUTH_ENABLED=true` and `RATE_LIMIT_ENABLED=true`. +- `TRUST_PROXY=true` for Umbrel app proxy headers. +- `DB_PATH=/app/data/igloo.db` (database mode). +- `HEADLESS=false` to serve UI assets. +- `ALLOWED_ORIGINS` defaults to `@self` plus `umbrel.local` variants; `@self` auto-allows the host users connect through. + +## Umbrel UI and Exports +- First run goes straight to account creation. The first user becomes admin. +- The Admin Secret is still visible on the Configure page for API usage. +- `packages/umbrel/igloo/exports.sh` surfaces values to Umbrel: + - `IGLOO_ADMIN_SECRET` from `APP_PASSWORD` + - `IGLOO_UI_URL` and `IGLOO_API_URL` from `APP_DOMAIN` + - `IGLOO_TOR_URL` from `APP_TOR_ADDRESS` + +## Operational Notes +- Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. +- The Umbrel store uses a pinned digest to avoid tag caching issues; update the digest on each new release. +- `packages/umbrel/igloo/docker-compose.yml` remains a sideload/dev bundle and still points at `:umbrel-dev` without a digest. + +## Update Checklist for Future Releases +1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). +2. Update `igloo-server-store/igloo-server/docker-compose.yml` to the new image digest. +3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. +4. Refresh gallery assets if the UI has changed. diff --git a/llm/implementation/umbrel-status.md b/llm/implementation/umbrel-status.md deleted file mode 100644 index 1d8fc91..0000000 --- a/llm/implementation/umbrel-status.md +++ /dev/null @@ -1,28 +0,0 @@ -# Igloo Server Umbrel Packaging Status (2025-12-12) - -## Current State -- **Images:** `ghcr.io/frostr-org/igloo-server:umbrel-dev` builds via `.github/workflows/umbrel-dev.yml` (smoke-test only, no push) and `:umbrel-`/`:umbrel-latest` publish via `.github/workflows/release.yml`. -- **Runtime user:** Image runs as non-root `igloo` (UID/GID 1000). Entrypoint creates `/app/data`, tries to chown/chmod 700, and logs a warning if host volume is owned by root. -- **Community store bundle:** `packages/umbrel/igloo/docker-compose.yml` points at `:umbrel-dev` tag and uses a named `app-data` volume; works when the volume is writable. -- **UI/UX:** Onboarding shows the instructions screen first; `SKIP_ADMIN_SECRET_VALIDATION=true` (default in compose) skips the admin-secret step. Configure page can reveal the admin secret for signed-in admins via `/api/env/admin-secret`. -- **CORS/WS:** `ALLOWED_ORIGINS` defaults to `@self,http://umbrel.local`; `@self` now also works when Umbrel app proxy sets `x-forwarded-host`. - -## Remaining Gaps -1) **Volume ownership on fresh installs:** Umbrel mounts `${APP_DATA_DIR}` as root. Our entrypoint (running as UID 1000) cannot chown the mount, so first-boot writes may fail unless the user fixes ownership manually. - - Current workaround (documented in `docs/DEPLOY.md`, Umbrel section): - ```bash - ssh umbrel@ - sudo mkdir -p /home/umbrel/umbrel/app-data/igloo-server/data - sudo chown -R 1000:1000 /home/umbrel/umbrel/app-data/igloo-server - sudo chmod 700 /home/umbrel/umbrel/app-data/igloo-server/data - ``` - - Proposed fix (not yet implemented): start container as root, run entrypoint to chown/chmod, then drop privileges with `su-exec`/`gosu` before `bun start`. -2) **Digest pinning:** Compose/manifest still reference the `:umbrel-dev` tag without a digest. Umbrel may cache an older tag. Need to rebuild, capture digest, and pin in `packages/umbrel/igloo/docker-compose.yml` and `umbrel-app.yml` before shipping. -3) **Docs alignment:** After privilege-drop + digest pinning land, refresh `docs/DEPLOY.md` and `packages/umbrel/igloo/README.md` to remove the SSH ownership workaround and describe the new entrypoint flow. -4) **Workflow exposure:** `umbrel-dev` GH Action only smoke-tests; no push occurs. Decide whether to push `:umbrel-dev`/commit tags from that workflow or document manual `docker buildx --push` for testers. - -## Next Actions -- Update `packages/umbrel/igloo/Dockerfile` to run entrypoint as root, install `su-exec` (or `gosu`), and drop to UID/GID 1000 after fixing `/app/data` permissions. -- Rebuild and push a fresh `:umbrel-dev`, record its digest, and pin compose/manifest to `@sha256:`. -- Validate on a clean Umbrel (no pre-chown) that install succeeds without SSH. Capture logs and screenshots. -- Update docs (`docs/DEPLOY.md`, bundle README) once automation is confirmed. From d2dd113779e4fcb387b094bb30877ad54db14ad0 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 15:07:49 -0600 Subject: [PATCH 09/69] docs updates and polish --- AGENTS.md | 5 +- CONTRIBUTING.md | 371 +++----------------- dockerfile => Dockerfile | 0 README.md | 7 +- compose.yml | 6 +- docs/AUTH_MATRIX.md | 40 +++ docs/CONFIG.md | 126 +++++++ docs/DEPLOY.md | 6 +- docs/PEER_POLICIES.md | 86 +++++ docs/README.md | 3 + docs/RELEASE.md | 16 +- docs/SECURITY.md | 14 +- docs/openapi/README.md | 5 +- docs/openapi/openapi.json | 77 +++- docs/openapi/openapi.yaml | 80 ++++- env.example | 134 ++++++- llm/context/API_REFERENCE.md | 126 +++++++ llm/context/ENVIRONMENT_VARIABLES.md | 128 ++++++- llm/implementation/umbrel-implementation.md | 4 +- llm/workflows/UMBREL_DEPLOYMENT.md | 2 +- 20 files changed, 869 insertions(+), 367 deletions(-) rename dockerfile => Dockerfile (100%) create mode 100644 docs/AUTH_MATRIX.md create mode 100644 docs/CONFIG.md create mode 100644 docs/PEER_POLICIES.md create mode 100644 llm/context/API_REFERENCE.md diff --git a/AGENTS.md b/AGENTS.md index 4d9fbca..dcc81cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,10 +11,10 @@ This guide keeps backend, frontend, and deployment workflows consistent for this - Co‑locate tests with code as `feature.test.ts` or `feature.spec.ts`. ## Build, Test, and Development Commands -- `bun run dev` — run backend, React, and Tailwind in watch mode. +- `bun run dev` — watch/rebuild frontend assets (Tailwind CSS + esbuild JS bundle). - `bun run build` — create production bundles. - `bun run build:dev` — readable bundles for debugging. -- `bun run start` — start packaged server; use `HEADLESS=true bun run start` to skip UI assets. +- `bun run start` — start the server (use a separate terminal alongside `bun run dev` during development); use `HEADLESS=true bun run start` to skip UI assets. - `bun test` — run backend tests. - `bun run docs:validate` — validate the OpenAPI contract. @@ -40,4 +40,3 @@ This guide keeps backend, frontend, and deployment workflows consistent for this - Load secrets from environment files or `data/` fixtures; never hard‑code. - Production: set `AUTH_ENABLED=true`, strong `ADMIN_SECRET`, and run behind TLS on `0.0.0.0`. - Tune `FROSTR_SIGN_TIMEOUT`, `SIGN_TIMEOUT_MS`, `AUTH_DERIVED_KEY_TTL_MS`, and `AUTH_DERIVED_KEY_MAX_READS` per environment. - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6921da5..6ec0062 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,359 +1,84 @@ # Contributing to Igloo Server -Thank you for your interest in contributing to Igloo Server! This guide will help you understand our development workflow and how to contribute effectively. +This repo has a backend (Bun + TypeScript) and a React/Tailwind UI bundled into `static/`. -## Development Workflow +Canonical contributor rules live in `AGENTS.md`. When this file and `AGENTS.md` disagree, follow `AGENTS.md`. -### Prerequisites -- **Bun runtime** (required) - This project uses Bun-specific APIs. Install from [bun.sh](https://bun.sh/) -- **Node.js 20+ and npm** (required for versioning and release tooling) -- **Git** for version control -- **Docker** (optional, for testing Docker builds) +## Quick Links +- Configuration reference: `docs/CONFIG.md` +- Deployment guide: `docs/DEPLOY.md` +- Security guide: `docs/SECURITY.md` +- Release flow: `docs/RELEASE.md` +- API contract: `docs/openapi/openapi.yaml` (validate with `bun run docs:validate`) -### Getting Started +## Prerequisites +- Bun (required) +- Git +- Docker (optional, for container builds/tests) -1. **Fork the repository** on GitHub -2. **Clone your fork**: - ```bash - git clone https://github.com/your-username/igloo-server.git - cd igloo-server - ``` - -3. **Install dependencies**: - ```bash - bun install - ``` - -4. **Create a feature branch**: - ```bash - git checkout -b feature/your-feature-name - ``` - -5. **Make your changes** and test locally: - ```bash - bun run build - bun run start - ``` - -6. **Commit your changes**: - ```bash - git commit -m "feat: add your feature description" - ``` - -7. **Push to your fork**: - ```bash - git push origin feature/your-feature-name - ``` - -8. **Create a Pull Request** on GitHub - -### Development Commands +## Local Development +Install: ```bash -# Start development server with hot reload -bun run dev - -# Build for production -bun run build - -# Build for development (no caching) -bun run build:dev - -# Start the server -bun run start - -# Clean build artifacts -bun run clean - -# Test Docker build -bun run docker:build -bun run docker:run - -# Validate OpenAPI documentation -bun run docs:validate -``` - -## Code Style & Standards - -### TypeScript Guidelines -- Use functional programming patterns over classes -- Prefer explicit types over `any` -- Add JSDoc comments for all public functions -- Use descriptive variable names with auxiliary verbs (e.g., `isLoading`, `hasError`) - -### File Organization -- Keep files under 500 lines for AI compatibility -- Use descriptive file names -- Add file header comments explaining the purpose -- Group related functionality into modules - -### Example Function Documentation -```typescript -/** - * Creates a new Bifrost node with the provided credentials - * @param groupCred - The FROSTR group credential - * @param shareCred - The FROSTR share credential - * @param relays - Array of relay URLs to connect to - * @returns Promise resolving to the connected node instance - */ -export async function createNodeWithCredentials( - groupCred: string, - shareCred: string, - relays: string[] -): Promise { - // Implementation... -} +bun install ``` -## Commit Message Format - -We use [Conventional Commits](https://www.conventionalcommits.org/) for consistent commit messages: - -``` -[optional scope]: - -[optional body] - -[optional footer(s)] -``` - -### Types -- `feat`: New feature -- `fix`: Bug fix -- `docs`: Documentation changes -- `style`: Code formatting changes -- `refactor`: Code refactoring -- `test`: Adding or updating tests -- `chore`: Maintenance tasks -- `security`: Security fixes - -### Examples +Build UI assets once (needed for first run): ```bash -git commit -m "feat: add peer status monitoring" -git commit -m "fix: resolve connection timeout issue" -git commit -m "docs: update API documentation" -git commit -m "chore: update dependencies" -``` - -## Release Process - -> 📖 **Quick Reference**: See [docs/RELEASE.md](docs/RELEASE.md) for a streamlined release guide - -### Automatic Releases - -The project uses automated releases through GitHub Actions: - -1. **Push to master** triggers automatic release detection -2. **Version bumping** is based on commit messages: - - `feat:` → minor version bump - - `fix:` → patch version bump - - `BREAKING CHANGE:` → major version bump - -3. **Manual releases** can be triggered via GitHub Actions UI - -### Quick Release Commands - -```bash -# For new features (minor version bump) -bun run release:minor - -# For bug fixes (patch version bump) -bun run release:patch - -# For breaking changes (major version bump) -bun run release:major -``` - -### Dev-to-Master Release Workflow - -When you're ready to create a new release, follow this process: - -#### 1. **Pre-Release Preparation** - -```bash -# Switch to dev branch and ensure it's up to date -git checkout dev -git pull origin dev - -# Run full test suite bun run build -bun run start # Verify server starts -docker build -t igloo-server-test . # Test Docker build - -# Validate documentation -bun run docs:validate ``` -#### 2. **Review and Prepare Release** - -- [ ] **Review all changes** since last release -- [ ] **Test critical functionality** (authentication, signing, recovery) -- [ ] **Update documentation** if needed -- [ ] **Check for breaking changes** requiring major version bump -- [ ] **Verify all PRs are properly merged** with conventional commit format - -#### 3. **Create Release PR** - +Run server: ```bash -# Create release preparation branch -git checkout -b release/prepare-vX.X.X -git push origin release/prepare-vX.X.X +bun run start ``` -Create a PR from `release/prepare-vX.X.X` → `master` with: -- **Title**: `release: prepare for vX.X.X` -- **Description**: Summary of changes, breaking changes, migration notes -- **Checklist**: Verify all tests pass, documentation updated - -#### 4. **Merge and Release** - -Once the release PR is approved: -1. **Merge release PR** to master (this triggers automatic release) -2. **Monitor GitHub Actions** for successful release -3. **Verify release artifacts**: - - GitHub release created - - Docker images published - - CHANGELOG.md updated -4. **Sync dev branch**: `git checkout dev && git merge master && git push origin dev` - -#### 5. **Post-Release Verification** - -- [ ] **Test Docker deployment**: `docker pull ghcr.io/frostr-org/igloo-server:latest` -- [ ] **Verify GitHub release** has correct assets and changelog -- [ ] **Update any dependent repositories** or documentation - -### Emergency Releases - -For critical bug fixes: - +Frontend watch (separate terminal): ```bash -# Create hotfix branch from master -git checkout master -git checkout -b hotfix/critical-fix - -# Make fix and commit -git commit -m "fix: critical security issue" - -# Push and create PR to master -git push origin hotfix/critical-fix +bun run dev ``` -### Release Commands +Notes: +- `bun run dev` only rebuilds `static/app.js` and `static/styles.css` (it does not start the server). +- Headless mode disables the frontend routes entirely (`HEADLESS=true`). -```bash -# Create a patch release (1.0.0 → 1.0.1) -bun run release:patch +## Tests and Validation -# Create a minor release (1.0.0 → 1.1.0) -bun run release:minor +```bash +# Backend tests +bun test -# Create a major release (1.0.0 → 2.0.0) -bun run release:major +# TypeScript typecheck +bun run typecheck -# Create a custom version -npm version 1.2.3 -git push origin master --tags +# Validate OpenAPI spec +bun run docs:validate ``` -### Release Artifacts - -Each release automatically creates: -- **GitHub Release** with changelog -- **Docker images** published to GitHub Container Registry -- **Source archives** (tar.gz) -- **Binary archives** (with built frontend) - -### Docker Images +## Docker +The repo uses a standard `Dockerfile` at the repo root: ```bash -# Latest release -docker pull ghcr.io/frostr-org/igloo-server:latest - -# Specific version -docker pull ghcr.io/frostr-org/igloo-server:1.0.0 +docker build -t igloo-server . ``` -## Testing - -### Manual Testing Checklist - -Before submitting a PR, please verify: - -- [ ] **Build succeeds**: `bun run build` -- [ ] **Server starts**: `bun run start` -- [ ] **Docker build works**: `docker build -t igloo-server .` -- [ ] **Frontend loads** at http://localhost:8002 -- [ ] **API endpoints respond** (e.g., `/api/status`) -- [ ] **API documentation loads** at http://localhost:8002/api/docs -- [ ] **OpenAPI spec is valid**: `bun run docs:validate` -- [ ] **No console errors** in browser or server logs - -### Automated Testing - -The CI pipeline runs: -- TypeScript compilation -- Build verification -- Server startup test -- Docker image build -- Security scanning - -## Pull Request Guidelines - -### Before Submitting -1. **Update documentation** if you change APIs -2. **Update OpenAPI spec** if you modify API endpoints (`docs/openapi/openapi.yaml`) -3. **Add tests** for new functionality -4. **Update CHANGELOG.md** if needed -5. **Verify your changes** don't break existing functionality - -### PR Description -Use the provided PR template and include: -- Clear description of changes -- Type of change (feature, bug fix, etc.) -- Testing steps -- Screenshots (if UI changes) -- Any breaking changes - -### Review Process -1. **Automated checks** must pass -2. **Code review** by maintainers -3. **Testing** in different environments -4. **Merge** to master branch - -## Security - -### Reporting Security Issues -Please report security vulnerabilities privately to: -- **Email**: security@frostr.org -- **GitHub**: Use private security advisories - -### Security Guidelines -- Never commit secrets or credentials -- Use environment variables for sensitive data -- Follow the security configuration guide in `docs/SECURITY.md` -- Keep dependencies updated - -## Getting Help - -### Resources -- **Documentation**: [README.md](README.md) -- **Security Guide**: [docs/SECURITY.md](docs/SECURITY.md) -- **Issues**: [GitHub Issues](https://github.com/FROSTR-ORG/igloo-server/issues) -- **Discussions**: [GitHub Discussions](https://github.com/FROSTR-ORG/igloo-server/discussions) +If you use `compose.yml`, note it uses `env_file: .env` to inject environment variables but does not mount that file into the container. For headless deployments that write config via `/api/env`, mount `.env` as a volume if you expect it to persist (see `docs/CONFIG.md`). -### Community -- **Discord**: [FROSTR Community](https://discord.gg/frostr) -- **Matrix**: [#frostr:matrix.org](https://matrix.to/#/#frostr:matrix.org) +## Coding Standards -## License +Follow `AGENTS.md`: +- TypeScript strict mode, explicit types, avoid `any`. +- Backend files kebab-case, React components PascalCase. +- Do not hand-edit generated assets in `static/`. -By contributing, you agree that your contributions will be licensed under the MIT License. +## PR Checklist (What We Expect) -## Recognition +1. `bun run build` +2. `bun test` +3. `bun run docs:validate` (when API/doc changes) +4. If Docker-related changes: `docker build -t igloo-server .` +5. If frontend changes: include screenshots in the PR -Contributors are recognized in: -- Release notes -- README.md contributors section -- GitHub contributors graph +## Release -Thank you for helping make Igloo Server better! 🎉 +Use `docs/RELEASE.md` (and `scripts/release.sh`) for the current release workflow. diff --git a/dockerfile b/Dockerfile similarity index 100% rename from dockerfile rename to Dockerfile diff --git a/README.md b/README.md index 2c58580..d600cc7 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ Looking to deploy quickly? Start with the one-click options in `docs/DEPLOY.md` ## Documentation - [docs/DEPLOY.md](docs/DEPLOY.md) — Umbrel, Docker/Compose, and cloud deployment steps - [docs/SECURITY.md](docs/SECURITY.md) — hardening, CSP, headers, and rate limiting guidance +- [docs/CONFIG.md](docs/CONFIG.md) — environment variables and operational tuning (CORS vs WS Origin, timeouts, restart/circuit knobs) +- [docs/AUTH_MATRIX.md](docs/AUTH_MATRIX.md) — which endpoints exist in which mode + what bypasses the global auth gate +- [docs/PEER_POLICIES.md](docs/PEER_POLICIES.md) — peer policy schema, precedence, and persistence - [docs/RELEASE.md](docs/RELEASE.md) — release workflow, automation, and emergency fixes - [docs/openapi/openapi.yaml](docs/openapi/openapi.yaml) — OpenAPI spec (served at `/api/docs`) @@ -53,7 +56,7 @@ bun run start ### Pick a Mode - Database (recommended): multi‑user, AES‑encrypted creds, admin onboarding via `ADMIN_SECRET`, SQLite at `./data/igloo.db` (override with `DB_PATH`). -- Headless: env‑only config, API‑first, UI disabled. Supports `PEER_POLICIES` blocks and API key auth. +- Headless: env‑only config, API‑first, UI disabled. Supports `PEER_POLICIES` blocks (see `docs/PEER_POLICIES.md`) and API key auth. ### Deployment Options @@ -81,7 +84,7 @@ Reverse proxy (nginx) and cloud steps are in docs/DEPLOY.md. ### Production Checklist - `NODE_ENV=production`, persist `/app/data`, set strong `ADMIN_SECRET` (keep set after onboarding). -- Explicit `ALLOWED_ORIGINS` (supports `@self` for “whatever host the user connects through”; host match, port-agnostic), `TRUST_PROXY=true` behind a proxy; forward WS upgrade headers. +- Explicit `ALLOWED_ORIGINS` (WebSocket Origin checks support `@self` for “whatever host the user connects through”; host match, port-agnostic), `TRUST_PROXY=true` behind a proxy; forward WS upgrade headers. - Auth on (`AUTH_ENABLED=true`), rate limit on (`RATE_LIMIT_ENABLED=true`); optional `SESSION_SECRET` (auto‑gen if absent). - Timeouts: tune `FROSTR_SIGN_TIMEOUT` or `SIGN_TIMEOUT_MS` (1000–120000ms). diff --git a/compose.yml b/compose.yml index e896688..b7c7c3d 100644 --- a/compose.yml +++ b/compose.yml @@ -1,7 +1,9 @@ services: igloo-server: - build : ./ + build: + context: ./ + dockerfile: Dockerfile env_file : .env image : igloo-server environment: @@ -28,6 +30,8 @@ services: - "8002:8002" volumes: - ./src:/app/src:rw + # Mount .env so /api/env writes persist back to the host file (env_file only injects values). + - ./.env:/app/.env:rw # Persist database and session secrets between container recreations - ./data:/app/data:rw diff --git a/docs/AUTH_MATRIX.md b/docs/AUTH_MATRIX.md new file mode 100644 index 0000000..099bb77 --- /dev/null +++ b/docs/AUTH_MATRIX.md @@ -0,0 +1,40 @@ +# Auth and Mode Matrix + +This is a human-oriented map of which endpoints exist in each mode and which ones bypass the global auth gate when `AUTH_ENABLED=true`. + +Definitions: +- Database mode: `HEADLESS=false` (default) +- Headless mode: `HEADLESS=true` +- Global auth gate: the router-level check that runs when `AUTH_ENABLED=true` and the path starts with `/api/`. + +"Bypasses global gate" does not mean "no auth required"; many endpoints enforce their own rules. + +## Endpoint Matrix + +| Endpoint(s) | Purpose | DB mode | Headless mode | Bypasses global auth gate when `AUTH_ENABLED=true` | Notes | +|---|---:|:---:|:---:|:---:|---| +| `/api/status` | Health/status | Yes | Yes | Yes | Public health checks; if auth headers are present the server will attempt auth and include extra details. | +| `/api/update` | Update check | Yes | Yes | Yes | Update checks are disabled for managed deployments (e.g., `HEADLESS=true` or `SKIP_ADMIN_SECRET_VALIDATION=true`) and when `UPDATE_CHECK_DISABLED=true`. | +| `/api/auth/status` | Auth capabilities | Yes | Yes | Yes | Returns configured auth methods and mode signals. | +| `/api/auth/login` | Session login | Yes | Yes | Yes | Creates a session when session auth is enabled; in headless mode with `API_KEY` set, sessions are disabled. | +| `/api/auth/logout` | Session logout | Yes | Yes | Yes | Clears session cookie / invalidates session when sessions are enabled. | +| `/api/onboarding/*` | First-run onboarding | Yes | No | Yes | Only mounted in DB mode. Intended to be unauthenticated; protected by rate limiting and `ADMIN_SECRET` (unless `SKIP_ADMIN_SECRET_VALIDATION=true`). | +| `/api/docs/*` | Swagger UI + raw spec | Yes | Yes | Special | Not behind the global gate, but in `NODE_ENV=production` with `AUTH_ENABLED=true` the docs require auth. | +| `/api/events` (WebSocket) | Server event stream | Yes | Yes | No | WebSocket upgrade is authorized like normal API requests when `AUTH_ENABLED=true`. Origin checks apply for browsers (see `docs/CONFIG.md`). | +| `/api/env`, `/api/env/delete` | Read/write env-backed config | Yes | Yes | No | DB mode: reads require a valid session when `AUTH_ENABLED=true`; writes require admin (`ADMIN_SECRET` or admin role). Headless: reads and writes require API key or Basic Auth even if `AUTH_ENABLED=false`. | +| `/api/env/shares` | Headless share metadata/upload | No | Yes | No | Intentionally headless-only; returns 404 in DB mode. | +| `/api/env/admin-secret` | Reveal `ADMIN_SECRET` (guarded) | Yes | No | No | DB mode only; requires an admin session and explicit confirmation in body. | +| `/api/peers/*` | Peer status/ping/policies | Yes | Yes | No | Policy mutations persist to DB for DB users; otherwise they persist to `data/peer-policies.json` (see `docs/PEER_POLICIES.md`). | +| `/api/recover/*` | Recovery workflows | Yes | Yes | No | Rate limited; intended for controlled use. | +| `/api/sign` | Threshold signing | Yes | Yes | No | Timeout controlled by `FROSTR_SIGN_TIMEOUT` / `SIGN_TIMEOUT_MS`. | +| `/api/nip44/*` | NIP-44 crypto | Yes | Yes | No | Timeout controlled by `FROSTR_SIGN_TIMEOUT` / `SIGN_TIMEOUT_MS`. | +| `/api/nip04/*` | NIP-04 crypto (legacy) | Yes | Yes | No | Timeout controlled by `FROSTR_SIGN_TIMEOUT` / `SIGN_TIMEOUT_MS`. | +| `/api/nip46/*` | NIP-46 APIs | Yes | No | No | DB mode only (not mounted in headless mode). | +| `/api/user/*` | Per-user credential storage | Yes | No | No | DB mode only; requires a DB-backed user session (env-auth users cannot use these endpoints). | +| `/api/admin/*` | Admin APIs (keys/users/status) | Yes | No | Yes (bypasses) | DB mode only; not behind the global gate. Still requires `ADMIN_SECRET` bearer or an admin session. | + +## Common Gotchas + +- `AUTH_ENABLED=false` is not "open everything". Some endpoints still require auth in headless mode (notably `/api/env*` reads/writes). Admin endpoints still require admin authorization. +- `/api/docs` is protected in production. In `NODE_ENV=production` with `AUTH_ENABLED=true`, you must authenticate to view Swagger UI. +- Mode differences are real API surface differences. Headless mode disables DB-only routes like `/api/user/*`, `/api/admin/*`, and `/api/nip46/*`. diff --git a/docs/CONFIG.md b/docs/CONFIG.md new file mode 100644 index 0000000..c317cd1 --- /dev/null +++ b/docs/CONFIG.md @@ -0,0 +1,126 @@ +# Configuration Guide + +This doc is the canonical reference for runtime configuration (environment variables), with emphasis on the knobs that most often affect security and production behavior. + +Related: +- `env.example` for a ready-to-copy baseline. +- `docs/SECURITY.md` for hardening guidance and recommended production defaults. + +## Modes + +Igloo Server runs in two modes: +- Database mode (default, `HEADLESS=false`): multi-user UI + encrypted credential storage in SQLite. +- Headless mode (`HEADLESS=true`): env-only credentials, API-first, no UI assets. + +Key differences: +- `API_KEY` is only used in headless mode (ignored in database mode). +- In headless mode with `API_KEY` set, session management is disabled (no `.session-secret` I/O). + +## Must-Set Values + +Database mode first run: +- `ADMIN_SECRET`: required when the database is uninitialized (onboarding). + +Headless mode: +- `GROUP_CRED` and `SHARE_CRED`: required to actually boot a signer node. + +Production (strongly recommended): +- `ALLOWED_ORIGINS`: explicit origins for browser access. +- `TRUST_PROXY=true` when running behind a reverse proxy that sets `X-Forwarded-*`. +- `AUTH_ENABLED=true` and `RATE_LIMIT_ENABLED=true`. + +## CORS vs WebSocket Origin (ALLOWED_ORIGINS) + +`ALLOWED_ORIGINS` is used by two different mechanisms with different semantics: + +HTTP (CORS headers): +- If `ALLOWED_ORIGINS` is unset and `NODE_ENV=production`: no `Access-Control-Allow-Origin` header is set, so browsers block cross-origin requests. +- If `ALLOWED_ORIGINS` is unset and `NODE_ENV` is not `production`: wildcard `*` is used for convenience. +- If `ALLOWED_ORIGINS` is set: it is treated as a comma-separated list of exact origins (or `*`). + +WebSocket (Origin enforcement on upgrades): +- If `ALLOWED_ORIGINS` is unset and `NODE_ENV` is not `production`: allow any Origin. +- If `ALLOWED_ORIGINS` is unset and `NODE_ENV=production`: allow only when `Origin` host matches the request `Host` (or `X-Forwarded-Host` if `TRUST_PROXY=true`). Ports are ignored for host matching. +- If `ALLOWED_ORIGINS` includes `@self`: allow any Origin whose host matches the request host (port-agnostic). +- If `NODE_ENV=production` and `ALLOWED_ORIGINS` includes `*`: reject the upgrade (wildcard is not allowed for WebSockets in production). +- Otherwise: require an explicit Origin match. + +Practical recipes: +- Same-host UI + API: `ALLOWED_ORIGINS=@self` +- Separate admin UI origin: `ALLOWED_ORIGINS=https://admin.example.com,https://api.example.com` +- Umbrel proxy: set `TRUST_PROXY=true` and include the proxied host in `ALLOWED_ORIGINS` (or use `@self`). + +## Sessions (SESSION_SECRET) + +`SESSION_SECRET` is a server-only enablement secret. It must never be exposed via API and is intentionally excluded from `/api/env` allowlists. + +Behavior: +- If unset, Igloo auto-generates a 32-byte secret (64 hex chars) and persists it to `/.session-secret` (or `./data/.session-secret` when `DB_PATH` is unset). +- In `NODE_ENV=production`, failure to load/generate/persist `SESSION_SECRET` is fatal (process exits). +- In headless mode with `API_KEY` set, sessions are disabled to avoid unnecessary file I/O. + +Operational implication: +- Persist your data directory (volume mount in Docker/Umbrel). Otherwise sessions will reset on every restart. + +## What The UI/API Can Change + +The `/api/env*` endpoints and Configure UI only allow writing a small whitelist of keys (see `src/routes/utils.ts` `ALLOWED_ENV_KEYS`). This includes: +- Credentials + relays: `GROUP_CRED`, `SHARE_CRED`, `RELAYS`, `GROUP_NAME`, `PEER_POLICIES` +- Selected tuning: `SESSION_TIMEOUT`, `FROSTR_SIGN_TIMEOUT`, `RATE_LIMIT_*`, `NODE_*` restart controls, `CONNECTIVITY_PING_TIMEOUT_MS`, `INITIAL_CONNECTIVITY_DELAY`, `ALLOWED_ORIGINS` + +Everything else must be configured outside the app (container env, systemd, `.env` on disk, etc.). + +Related docs: +- `docs/AUTH_MATRIX.md` for mode-by-mode endpoint availability and what bypasses the global auth gate. +- `docs/PEER_POLICIES.md` for peer policy schema, precedence, and persistence. + +Important persistence/precedence detail: +- For keys managed by `/api/env`, Igloo reads from a local `.env` file (relative to the server working directory) and treats it as higher precedence than process environment variables for those keys. +- If you deploy with Docker Compose `env_file: .env`, note that this sets container environment variables but does not mount the file into the container. In headless deployments, UI/API changes that write `.env` will not persist across container recreation unless you also mount a volume for the `.env` file (or you manage configuration exclusively via container environment variables and avoid writing via `/api/env`). + +## Operational Tuning Knobs (Most Commonly Missed) + +Timeouts: +- `FROSTR_SIGN_TIMEOUT` (preferred) or `SIGN_TIMEOUT_MS` (legacy): clamps to 1000..120000ms. +- `CONNECTIVITY_PING_TIMEOUT_MS` (or `PING_TIMEOUT_MS`): connectivity ping timeout, clamps to 1000..120000ms. +- `PUBLISH_EVENT_TIMEOUT_MS` (or `RELAY_PUBLISH_TIMEOUT`): relay publish receipt timeout, clamps to 1000..120000ms. + +WebSocket abuse controls: +- `RATE_LIMIT_WS_UPGRADE_WINDOW`, `RATE_LIMIT_WS_UPGRADE_MAX` +- `WS_MAX_CONNECTIONS_PER_IP`, `WS_MSG_RATE`, `WS_MSG_BURST` + +Recovery throttling: +- `RATE_LIMIT_RECOVERY_WINDOW`, `RATE_LIMIT_RECOVERY_MAX` + +Node restart/backoff: +- `NODE_RESTART_DELAY` (ms), `NODE_MAX_RETRIES`, `NODE_BACKOFF_MULTIPLIER`, `NODE_MAX_RETRY_DELAY` (ms) + +Error circuit breaker (exits process after repeated unhandled errors): +- `ERROR_CIRCUIT_WINDOW_MS`, `ERROR_CIRCUIT_THRESHOLD`, `ERROR_CIRCUIT_EXIT_CODE` + +Update checks (`GET /api/update`): +- `UPDATE_CHECK_DISABLED` +- `MANAGED_DEPLOYMENT` (also treated as managed when `HEADLESS=true` or `SKIP_ADMIN_SECRET_VALIDATION=true`) +- `GITHUB_TOKEN` (avoids GitHub API rate limits) +- `UPDATE_CHECK_TIMEOUT_MS`, `UPDATE_CHECK_TTL_MS`, `UPDATE_CHECK_FAILURE_TTL_MS` +- `APP_VERSION` (override what `/api/update` reports; intended for packaged builds) + +Onboarding hardening (database mode only): +- `FINGERPRINT_SECRET` (stabilizes per-client identifiers across restarts) +- `CLIENT_ID_TTL_MS` (bounds in-memory client-id cache) +- `LOG_FINGERPRINT_FALLBACK=true` (diagnostic logging; avoid in production unless troubleshooting) + +Performance toggles (advanced): +- `SKIP_RELAY_PROBE`, `DEFER_RELAY_PROBE` (relay probing behavior) +- `SKIP_STARTUP_ECHO` (skips headless startup echo broadcasts) +- `MAX_PEER_STATUS_ENTRIES` (bounds peer status memory) + +## DB_PATH Semantics + +`DB_PATH` can be either: +- A directory (e.g., `/var/lib/igloo/data`), or +- A file path (e.g., `/var/lib/igloo/igloo.db`) + +In both cases, Igloo stores: +- SQLite at `/igloo.db` when `DB_PATH` is a directory (or uses the file path directly when it looks like a file) +- Session secret at the inferred directory `/.session-secret` diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index b6e5e28..9032763 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -2,6 +2,8 @@ This page collects detailed deploy steps and reverse‑proxy examples that were trimmed from README for brevity. +Configuration reference: `docs/CONFIG.md` (env vars, CORS vs WS Origin semantics, operational tuning). + ## Umbrel (App Store, 1.1.0+) Use the packaged Umbrel app if you prefer a one-click install on your node. The bundle runs **Database mode** by default and persists `/app/data` on Umbrel’s volume. @@ -20,13 +22,15 @@ Use the packaged Umbrel app if you prefer a one-click install on your node. The You can skip cloning and building; pull the published image from GHCR. +If you do build locally, the repo uses a standard `Dockerfile` at the repo root. + 1) Create a Droplet (Ubuntu 22.04+; 2GB RAM recommended) and install Docker + Compose: ```bash curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh sudo curl -L "https://github.com/docker/compose/releases/download/v2.20.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose sudo chmod +x /usr/local/bin/docker-compose ``` -2) Pull and run (pin a release tag for reproducibility, e.g., `1.4.2` or `umbrel-1.4.2`): +2) Pull and run (pin a release tag for reproducibility, e.g., `1.1.1` or `umbrel-1.1.1`): ```bash docker pull ghcr.io/frostr-org/igloo-server:latest docker run -d --name igloo-server -p 8002:8002 \ diff --git a/docs/PEER_POLICIES.md b/docs/PEER_POLICIES.md new file mode 100644 index 0000000..0c1ebab --- /dev/null +++ b/docs/PEER_POLICIES.md @@ -0,0 +1,86 @@ +# Peer Policies + +Peer policies are directional allow/deny rules attached to peer pubkeys. They let you block outbound and/or inbound traffic with a given peer without rotating credentials. + +Terminology: +- `allowSend`: whether this node is allowed to send messages to the peer +- `allowReceive`: whether this node is allowed to accept messages from the peer +- Default is allow/allow when no explicit policy exists. + +## Where Policies Come From + +There are three inputs/persistence layers: + +1. `PEER_POLICIES` (environment) +This is a JSON object or array of objects. It is parsed and normalized during node creation. Use it as a "baseline" policy that ships with your deployment. + +2. Database persistence (DB mode, per-user) +When a DB user updates policies via the API, the server persists a sanitized representation into that user's `users.peer_policies` column. These per-user policies are applied when the node is started from that user's stored credentials (for example, via `GET /api/user/credentials` auto-start). + +3. Fallback store: `data/peer-policies.json` +When the server cannot associate a policy change with a DB user (headless mode, or env-auth in DB mode), it persists policies to `./data/peer-policies.json` (relative to the server working directory). The server also mirrors DB-user policy changes into this file so headless/API clients can retain overrides across restarts. + +Important path nuance: +- The fallback store always uses `./data/peer-policies.json` and does not follow `DB_PATH`. In container deployments, mount `./data` (or ensure the working directory's `data/` is persisted). + +## Precedence (What Wins) + +When a node is created, the server applies policies in this order: + +1. Base policies from the raw string passed into node creation. +This comes from `PEER_POLICIES` env for env-backed nodes (common in headless mode), or from the DB user's stored policies when the node is started from user credentials. + +2. Overrides from `data/peer-policies.json`. +These are loaded and merged over the base. Field-level overrides win (for example, an override can set `allowSend:false` even if the base is absent or different). + +Operational implication: +- `data/peer-policies.json` is treated as the "last known overrides" layer and can override `PEER_POLICIES` at startup. + +## Persistence Rules (What Actually Gets Stored) + +To avoid noise and accidental "permit lists", the server sanitizes policies before persisting them: +- Only entries with at least one non-default override are stored. +- A "non-default override" includes `allowSend:false`, `allowReceive:false`, `label`, and `note`. +- `allowSend:true` / `allowReceive:true` are defaults and are not stored. + +To remove a deny, delete the policy entry entirely (or update it so it has no non-default overrides). + +## Schema (Recommended) + +The server accepts a superset of fields (via igloo-core normalization), but the persisted/sanitized shape is intentionally small. + +Recommended JSON shape (array form): +```json +[ + { + "pubkey": "f3b0...xonlyhex", + "allowSend": false, + "allowReceive": true, + "label": "blocked-peer", + "note": "Temporary outbound block" + }, + { + "pubkey": "a1c2...xonlyhex", + "allowReceive": false, + "note": "Do not accept inbound requests from this peer" + } +] +``` + +Notes: +- Use x-only hex pubkeys when possible. The server normalizes pubkeys internally. +- If you provide a single object instead of an array, it is treated as a one-element array. + +## How To Manage Policies + +Environment baseline (headless-friendly): +- Set `PEER_POLICIES` to a JSON array (or single object) and restart the server. + +API-driven changes: +- Use the `/api/peers/*` policy endpoints to inspect and mutate policies at runtime. +- In DB mode, changes made as an authenticated DB user persist to that user and are mirrored into `data/peer-policies.json`. +- In headless mode (or env-auth), changes persist to `data/peer-policies.json`. + +If you are unsure which layer is currently active for your deployment, check: +- `docs/AUTH_MATRIX.md` for mode differences and auth expectations. +- `docs/CONFIG.md` for persistence notes and volume mounting gotchas. diff --git a/docs/README.md b/docs/README.md index da3d92e..5b8fe71 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,9 @@ - **Deploy** — `docs/DEPLOY.md`: Umbrel App Store (1.1.0+), Docker/Compose, reverse proxy, prod checklist. - **Security** — `docs/SECURITY.md`: hardening, auth defaults, CSP/headers, rate limits, secrets handling. +- **Config** — `docs/CONFIG.md`: environment variables, CORS/WS Origin semantics, and operational tuning. +- **Auth Matrix** — `docs/AUTH_MATRIX.md`: endpoint availability by mode + what bypasses the global auth gate. +- **Peer Policies** — `docs/PEER_POLICIES.md`: schema, precedence, and persistence for directional peer policies. - **Release** — `docs/RELEASE.md`: how we cut tags, build images (incl. Umbrel), and emergency fixes. - **API** — `docs/openapi/README.md`: OpenAPI 3.1 source, `/api/docs` usage, lint/bundle commands. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 93737c9..ab3ef2c 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -5,10 +5,10 @@ Quick reference for releasing Igloo Server. ## 🚀 Quick Release (Recommended) ```bash -# For PR #11 (major new features) +# For new features (minor) bun run release:minor -# For bug fixes +# For bug fixes (patch) bun run release:patch # For breaking changes @@ -34,10 +34,10 @@ bun run docs:validate ### 2. Create Release PR ```bash -git checkout -b release/prepare-v0.2.0 -git push origin release/prepare-v0.2.0 +git checkout -b release/prepare-v1.1.1 +git push origin release/prepare-v1.1.1 ``` -Create PR: `release/prepare-v0.2.0` → `master` +Create PR: `release/prepare-v1.1.1` → `master` ### 3. Merge & Release - Merge PR to `master` @@ -58,9 +58,9 @@ GitHub Actions automatically detects version type from commit messages: | Commit Message | Version Bump | Example | |----------------|--------------|---------| -| `feat:` | Minor | 0.1.7 → 0.2.0 | -| `fix:` | Patch | 0.1.7 → 0.1.8 | -| `BREAKING CHANGE:` | Major | 0.1.7 → 1.0.0 | +| `feat:` | Minor | 1.1.1 → 1.2.0 | +| `fix:` | Patch | 1.1.1 → 1.1.2 | +| `BREAKING CHANGE:` | Major | 1.1.1 → 2.0.0 | ## 🚨 Emergency Releases diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 201aef9..f515e26 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,6 +2,8 @@ This guide covers security best practices for deploying and configuring your igloo-server, which handles sensitive FROSTR credentials and signing operations. +For a complete environment-variable reference (including CORS vs WebSocket Origin behavior), see `docs/CONFIG.md`. + ## 🎯 Operation Modes Igloo Server supports two operation modes with different security models: @@ -17,7 +19,7 @@ Igloo Server supports two operation modes with different security models: - **Direct credential storage** in environment - **Traditional deployment** for backward compatibility - **No database required** -- **Peer policies**: only explicit blocks are honored via `PEER_POLICIES` or `data/peer-policies.json` +- **Peer policies**: directional blocks can be supplied via `PEER_POLICIES` or persisted in `data/peer-policies.json` (see `docs/PEER_POLICIES.md`) - **API key**: set via the `API_KEY` environment variable; only one value is supported and rotation requires updating the env var and restarting the server ### Security Comparison Table @@ -214,6 +216,12 @@ RATE_LIMIT_ENABLED=true Configure rate limiting to prevent abuse: +Defaults (if unset): +- Headless mode (`HEADLESS=true`): `RATE_LIMIT_MAX=300` per `RATE_LIMIT_WINDOW=900` seconds +- Database mode (`HEADLESS=false`): `RATE_LIMIT_MAX=600` per `RATE_LIMIT_WINDOW=900` seconds + +For internet-exposed deployments you should set these explicitly. The example below is a conservative starting point: + ```bash RATE_LIMIT_ENABLED=true RATE_LIMIT_WINDOW=900 # 15 minutes @@ -421,7 +429,7 @@ RATE_LIMIT_MAX=50 # Strict limiting NODE_ENV=production ``` -### Headless Mode Deployments (Legacy) +### Headless Mode Deployments (Compatibility) #### 1. Single-User Setup (Personal) ```bash @@ -664,4 +672,4 @@ for i in {1..10}; do curl http://localhost:8002/api/status; done 4. **Directional Peer Policies** (optional): - Defaults allow both inbound and outbound traffic. - To deny a direction, supply `allowSend:false` and/or `allowReceive:false` in `PEER_POLICIES`. - - The server mirrors saved overrides into `data/peer-policies.json` so they persist between restarts. + - The server persists and mirrors saved overrides into `data/peer-policies.json` so they persist between restarts (see `docs/PEER_POLICIES.md`). diff --git a/docs/openapi/README.md b/docs/openapi/README.md index 4fca52d..98a01a9 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -34,6 +34,9 @@ This writes the required files to `static/docs/`. ## Using the API Documentation +Mode and auth nuance: +- Some endpoints are only mounted in Database mode or Headless mode. See `docs/AUTH_MATRIX.md` for the authoritative matrix. + ### Authentication for API Documentation - **Development**: No authentication required for easy testing @@ -82,7 +85,7 @@ The OpenAPI specification includes (major surfaces): - ✅ Error response formats - ✅ Comprehensive examples -> Note: Some supportive endpoints (e.g., onboarding `/api/onboarding/*`, admin `whoami`/`users`, and user storage `/api/user/*`) are available in the server but not yet modeled in the OpenAPI. Use the README “API Reference” and the UI for details. These may be added to the spec in a future update. +> Note: Some supportive endpoints are implemented but intentionally not modeled in OpenAPI yet (or are difficult to model faithfully), such as the Swagger UI asset routes under `/api/docs/assets/*`, the docs UI HTML at `/api/docs`, update checks at `/api/update`, and several operational/utility endpoints. When in doubt, treat `src/routes/*.ts` and `src/server.ts` as runtime truth. ## Validation diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 1fa043c..d2a020b 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.1.0", "info": { "title": "Igloo Server API", - "description": "A server-based signing device and personal ephemeral relay for the FROSTR protocol.\n\n## Health Monitoring & Auto-Restart\n\nIgloo Server includes comprehensive health monitoring with automatic restart capabilities:\n- **Activity Tracking**: Monitors node activity and connection status\n- **Health Checks**: Automated health checks every 30 seconds\n- **Auto-Restart**: Automatic recovery from silent failures (5-minute watchdog)\n- **Connection Resilience**: Enhanced reconnection with exponential backoff\n\n## Security Requirements\n\n**HTTPS is mandatory for all production deployments.** This API handles sensitive cryptographic operations and credentials that must be protected in transit.\n\n## Authentication\n\nThe Igloo Server supports multiple authentication methods:\n- **API Key**: Use `X-API-Key` header or `Authorization: Bearer ` (HTTPS required)\n- **Basic Auth**: Standard HTTP Basic Authentication (HTTPS strongly required)\n- **Session**: Cookie-based sessions for web UI (HTTPS required)\n\n**Production Recommendation:** For enhanced security in production environments, consider implementing OAuth2/OIDC flows with proper token management, rotation, and scope control.\n\n## Rate Limiting\n\nAPI requests are rate limited per IP address. Default limits:\n- 100 requests per 15-minute window\n- Rate limit headers included in responses\n\n## CORS\n\nCross-origin requests are supported with configurable origins via `ALLOWED_ORIGINS` environment variable.\n", - "version": "0.1.7", + "description": "A server-based signing device and personal ephemeral relay for the FROSTR protocol.\n\n## Health Monitoring & Auto-Restart\n\nIgloo Server includes comprehensive health monitoring with automatic restart capabilities:\n- **Activity Tracking**: Monitors node activity and connection status\n- **Health Checks**: Automated health checks every 30 seconds\n- **Auto-Restart**: Automatic recovery from silent failures (5-minute watchdog)\n- **Connection Resilience**: Enhanced reconnection with exponential backoff\n\n## Security Requirements\n\n**HTTPS is mandatory for all production deployments.** This API handles sensitive cryptographic operations and credentials that must be protected in transit.\n\n## Authentication\n\nThe Igloo Server supports multiple authentication methods:\n- **API Key**: Use `X-API-Key` header or `Authorization: Bearer ` (HTTPS required)\n- **Basic Auth**: Standard HTTP Basic Authentication (HTTPS strongly required)\n- **Session**: Cookie-based sessions for web UI (HTTPS required)\n\n**Production Recommendation:** For enhanced security in production environments, consider implementing OAuth2/OIDC flows with proper token management, rotation, and scope control.\n\n## Rate Limiting\n\nAPI requests are rate limited per IP address.\n\nDefaults are mode-dependent:\n- Headless mode (`HEADLESS=true`): 300 requests per 15-minute window\n- Database mode (`HEADLESS=false`): 600 requests per 15-minute window\n\nTune `RATE_LIMIT_WINDOW` and `RATE_LIMIT_MAX` explicitly for production.\nMost endpoints return `429` with `Retry-After` when rate limited (some endpoints may include additional `X-RateLimit-*` headers).\n\n## CORS\n\nCross-origin requests are supported with configurable origins via `ALLOWED_ORIGINS` environment variable.\n", + "version": "1.1.1", "contact": { "name": "FROSTR Organization", "url": "https://github.com/FROSTR-ORG/igloo-server" @@ -149,6 +149,79 @@ } } }, + "/api/events": { + "get": { + "operationId": "eventsWebSocket", + "summary": "WebSocket event stream", + "description": "Real-time server event stream using a WebSocket upgrade.\n\nThis endpoint only upgrades when the request includes `Upgrade: websocket`.\n\nAuthentication:\n- If `AUTH_ENABLED=true`, the upgrade is authorized using the same auth methods as HTTP requests (API key, Bearer, Basic, or session).\n- For non-browser clients, credentials may be provided via `Sec-WebSocket-Protocol` hints (first offered protocol is echoed back):\n - `apikey.` or `api-key.` maps to `X-API-Key: `\n - `bearer.` maps to `Authorization: Bearer `\n - `session.` maps to `X-Session-ID: `\n- Legacy query params `apiKey` and `sessionId` are supported for compatibility but are discouraged.\n\nSecurity:\n- Origin checks apply to browser WebSocket upgrades (configure `ALLOWED_ORIGINS`; wildcard `*` is rejected in production).\n- Upgrades are rate limited (see `RATE_LIMIT_WS_UPGRADE_*`) and connections are capped per IP (`WS_MAX_CONNECTIONS_PER_IP`).\n\nMessage format:\n- The stream emits JSON objects shaped like `{ type, message, data?, timestamp, id }`.\n", + "tags": [ + "Events" + ], + "responses": { + "101": { + "description": "Switching Protocols (WebSocket upgrade accepted)" + }, + "200": { + "description": "WebSocket event stream (documentation-only). In practice, successful upgrades return `101 Switching Protocols`.\n" + }, + "400": { + "description": "WebSocket upgrade failed (plain text)", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "WebSocket upgrade failed" + } + } + }, + "401": { + "description": "Unauthorized (plain text)", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Unauthorized" + } + } + }, + "403": { + "description": "Forbidden (origin rejected; plain text)", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Forbidden" + } + } + }, + "429": { + "description": "Too many WebSocket attempts or too many concurrent connections (plain text)", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Too many WebSocket attempts" + } + } + }, + "503": { + "description": "Service temporarily unavailable (plain text)", + "content": { + "text/plain": { + "schema": { + "type": "string" + }, + "example": "Service temporarily unavailable" + } + } + } + } + } + }, "/api/auth/status": { "get": { "operationId": "getAuthStatus", diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 53b4f0e..6f20907 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -27,14 +27,19 @@ info: ## Rate Limiting - API requests are rate limited per IP address. Default limits: - - 100 requests per 15-minute window - - Rate limit headers included in responses + API requests are rate limited per IP address. + + Defaults are mode-dependent: + - Headless mode (`HEADLESS=true`): 300 requests per 15-minute window + - Database mode (`HEADLESS=false`): 600 requests per 15-minute window + + Tune `RATE_LIMIT_WINDOW` and `RATE_LIMIT_MAX` explicitly for production. + Most endpoints return `429` with `Retry-After` when rate limited (some endpoints may include additional `X-RateLimit-*` headers). ## CORS Cross-origin requests are supported with configurable origins via `ALLOWED_ORIGINS` environment variable. - version: 0.1.7 + version: 1.1.1 contact: name: FROSTR Organization url: https://github.com/FROSTR-ORG/igloo-server @@ -102,6 +107,73 @@ paths: '500': $ref: '#/components/responses/InternalServerError' + /api/events: + get: + operationId: eventsWebSocket + summary: WebSocket event stream + description: | + Real-time server event stream using a WebSocket upgrade. + + This endpoint only upgrades when the request includes `Upgrade: websocket`. + + Authentication: + - If `AUTH_ENABLED=true`, the upgrade is authorized using the same auth methods as HTTP requests (API key, Bearer, Basic, or session). + - For non-browser clients, credentials may be provided via `Sec-WebSocket-Protocol` hints (first offered protocol is echoed back): + - `apikey.` or `api-key.` maps to `X-API-Key: ` + - `bearer.` maps to `Authorization: Bearer ` + - `session.` maps to `X-Session-ID: ` + - Legacy query params `apiKey` and `sessionId` are supported for compatibility but are discouraged. + + Security: + - Origin checks apply to browser WebSocket upgrades (configure `ALLOWED_ORIGINS`; wildcard `*` is rejected in production). + - Upgrades are rate limited (see `RATE_LIMIT_WS_UPGRADE_*`) and connections are capped per IP (`WS_MAX_CONNECTIONS_PER_IP`). + + Message format: + - The stream emits JSON objects shaped like `{ type, message, data?, timestamp, id }`. + tags: + - Events + responses: + '200': + description: | + WebSocket event stream (documentation-only). In practice, successful upgrades return `101 Switching Protocols`. + '101': + description: Switching Protocols (WebSocket upgrade accepted) + '400': + description: WebSocket upgrade failed (plain text) + content: + text/plain: + schema: + type: string + example: WebSocket upgrade failed + '401': + description: Unauthorized (plain text) + content: + text/plain: + schema: + type: string + example: Unauthorized + '403': + description: Forbidden (origin rejected; plain text) + content: + text/plain: + schema: + type: string + example: Forbidden + '429': + description: Too many WebSocket attempts or too many concurrent connections (plain text) + content: + text/plain: + schema: + type: string + example: Too many WebSocket attempts + '503': + description: Service temporarily unavailable (plain text) + content: + text/plain: + schema: + type: string + example: Service temporarily unavailable + /api/auth/status: get: operationId: getAuthStatus diff --git a/env.example b/env.example index 4422a0e..04c236f 100644 --- a/env.example +++ b/env.example @@ -69,7 +69,11 @@ AUTH_ENABLED=true # Examples: # Development: http://localhost:3000,http://localhost:8002 # Production: https://yourdomain.com,https://admin.yourdomain.com -# If not set, defaults to '*' (all origins) - NOT RECOMMENDED for production +# If not set: +# - In development: wildcard (*) is used for convenience. +# - In production: no CORS header is set, so browsers block cross-origin requests. +# WebSocket upgrades use a separate Origin enforcement path and support a special token: +# - `@self` allows any Origin whose host matches the request host (port-agnostic). ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8002 # API Key Authentication @@ -83,10 +87,13 @@ BASIC_AUTH_USER=admin BASIC_AUTH_PASS=your-secure-password-here # Session Management -# Generate session secret with: openssl rand -hex 32 -# REQUIRED in production to prevent session invalidation on server restarts -# Used for web UI session cookies -SESSION_SECRET=your-random-session-secret-here +# Used for web UI session cookies. +# If unset, the server auto-generates a 32-byte secret (64 hex chars) and persists it to: +# - `/.session-secret` (or `./data/.session-secret` when `DB_PATH` is unset). +# In `NODE_ENV=production`, failure to load/generate/persist is fatal (process exits). +# In headless mode with `API_KEY` set, sessions are disabled to avoid file I/O. +# Optional override (must be 64 hex chars): +# SESSION_SECRET=your-64-hex-session-secret-here SESSION_TIMEOUT=3600 # Session timeout in seconds (3600 = 1 hour) # ============================================================================= @@ -101,12 +108,129 @@ RATE_LIMIT_WINDOW=900 # Maximum requests per window per IP address RATE_LIMIT_MAX=600 +# WebSocket upgrade abuse protection (both /api/events and / WebSocket upgrades) +# Window defaults to RATE_LIMIT_WINDOW when unset; max defaults to 30 +# RATE_LIMIT_WS_UPGRADE_WINDOW=900 +# RATE_LIMIT_WS_UPGRADE_MAX=30 +# +# WebSocket per-IP connection cap and message rate limiting +# WS_MAX_CONNECTIONS_PER_IP=5 +# WS_MSG_RATE=20 +# WS_MSG_BURST=40 + # NIP-46 session creation limits (per user) # Defaults: 1 hour window across modes NIP46_SESSION_RATE_LIMIT_WINDOW=3600 # Defaults: 30 sessions/hour in HEADLESS mode, 120 sessions/hour when DB backed NIP46_SESSION_RATE_LIMIT_MAX=120 +# ============================================================================= +# OPERATION TIMEOUTS (ADVANCED) +# ============================================================================= +# Signing operation timeout (ms). Alias: SIGN_TIMEOUT_MS (legacy). +# Clamped to 1000..120000ms. +# FROSTR_SIGN_TIMEOUT=30000 +# SIGN_TIMEOUT_MS=30000 +# +# Connectivity ping timeout (ms). Alias: PING_TIMEOUT_MS (legacy). +# Clamped to 1000..120000ms. +# CONNECTIVITY_PING_TIMEOUT_MS=10000 +# PING_TIMEOUT_MS=10000 +# +# Relay publish receipt timeout (ms). Alias: RELAY_PUBLISH_TIMEOUT (legacy). +# Clamped to 1000..120000ms. +# PUBLISH_EVENT_TIMEOUT_MS=30000 +# RELAY_PUBLISH_TIMEOUT=30000 +# +# Startup echo timeouts (ms). Alias: ECHO_TIMEOUT_MS (legacy). +# Clamped to 1000..60000ms. +# SELF_ECHO_TIMEOUT_MS=10000 +# ECHO_TIMEOUT_MS=10000 + +# ============================================================================= +# WEBSOCKET ABUSE PROTECTION (ADVANCED) +# ============================================================================= +# WebSocket upgrade rate limiting (both `/api/events` and `/` upgrades). +# Defaults: window=RATE_LIMIT_WINDOW, max=30. +# RATE_LIMIT_WS_UPGRADE_WINDOW=900 +# RATE_LIMIT_WS_UPGRADE_MAX=30 +# +# Per-IP connection cap and message rate limiting (token bucket). +# WS_MAX_CONNECTIONS_PER_IP=5 +# WS_MSG_RATE=20 +# WS_MSG_BURST=40 + +# ============================================================================= +# RECOVERY RATE LIMITS (ADVANCED) +# ============================================================================= +# Recovery endpoint throttling (defaults: window=RATE_LIMIT_WINDOW, max=3). +# RATE_LIMIT_RECOVERY_WINDOW=900 +# RATE_LIMIT_RECOVERY_MAX=3 + +# ============================================================================= +# NODE LIFECYCLE & RELAY PROBING (ADVANCED) +# ============================================================================= +# Relay probing: +# - SKIP_RELAY_PROBE=true: skip probing entirely (fastest startup). +# - DEFER_RELAY_PROBE=true: start immediately; probe in background for diagnostics (ignored if SKIP_RELAY_PROBE=true). +# SKIP_RELAY_PROBE=false +# DEFER_RELAY_PROBE=false +# +# Skip headless startup echo broadcasts (perf option). +# SKIP_STARTUP_ECHO=false +# +# Bounds peer status memory (FIFO eviction). +# MAX_PEER_STATUS_ENTRIES=1000 + +# ============================================================================= +# ERROR CIRCUIT BREAKER (ADVANCED) +# ============================================================================= +# Exit the process after repeated unhandled exceptions (for supervisors to restart cleanly). +# ERROR_CIRCUIT_WINDOW_MS=60000 +# ERROR_CIRCUIT_THRESHOLD=10 +# ERROR_CIRCUIT_EXIT_CODE=1 + +# ============================================================================= +# UPDATE CHECKS (`GET /api/update`) (ADVANCED) +# ============================================================================= +# Disable update checks. +# UPDATE_CHECK_DISABLED=false +# +# Mark deployment as managed (also treated as managed when HEADLESS=true or SKIP_ADMIN_SECRET_VALIDATION=true). +# MANAGED_DEPLOYMENT=false +# +# GitHub API token to avoid rate limits (optional). +# GITHUB_TOKEN= +# +# Update check timeouts and cache TTLs (ms). +# UPDATE_CHECK_TIMEOUT_MS=5000 +# UPDATE_CHECK_TTL_MS=21600000 # 6 hours +# UPDATE_CHECK_FAILURE_TTL_MS=900000 # 15 minutes +# +# Override version reported by `/api/update` (intended for packaged builds). +# APP_VERSION= + +# ============================================================================= +# ONBOARDING HARDENING (DB MODE ONLY) (ADVANCED) +# ============================================================================= +# Stabilizes per-client identifiers across restarts; leave unset to use a best-effort fallback. +# FINGERPRINT_SECRET= +# +# Bounds the in-memory client-id cache lifetime (ms). Default 86400000, clamped 10m..7d. +# CLIENT_ID_TTL_MS=86400000 +# +# Diagnostic logging for fingerprint fallbacks (avoid in production unless troubleshooting). +# LOG_FINGERPRINT_FALLBACK=false + +# ============================================================================= +# MANAGED INSTALL FLAGS (ADVANCED) +# ============================================================================= +# Skip the onboarding "Enter Admin Secret" UI step (Umbrel-style managed deployments only). +# SKIP_ADMIN_SECRET_VALIDATION=false +# +# Auto-generate an ephemeral ADMIN_SECRET in CI/test or when explicitly enabled (non-production only). +# AUTO_ADMIN_SECRET=false + # ============================================================================= # ENVIRONMENT MODE # ============================================================================= diff --git a/llm/context/API_REFERENCE.md b/llm/context/API_REFERENCE.md new file mode 100644 index 0000000..4859228 --- /dev/null +++ b/llm/context/API_REFERENCE.md @@ -0,0 +1,126 @@ +# Igloo Server API Reference (LLM) + +Last verified: 2026-02-09 + +## Canonical Sources +- Primary contract: `docs/openapi/openapi.yaml` (OpenAPI 3.1). +- Runtime truth for edge cases and missing endpoints: `src/routes/*.ts` and `src/server.ts`. + +## Modes and Auth Summary +- Database mode (`HEADLESS=false`): UI enabled, SQLite-backed users, sessions persisted, DB API keys supported, env `API_KEY` ignored. +- Headless mode (`HEADLESS=true`): no UI, DB-only routes disabled, env `API_KEY` and Basic Auth are primary; sessions are optional and disabled when `API_KEY` is set. +- Global auth gate: with `AUTH_ENABLED=true`, all `/api/*` endpoints require auth except `GET /api/status`, `/api/auth/*`, `/api/onboarding/*` (DB only), and `GET /api/update`. +- `GET /api/status` is always public; in DB mode it returns `hasCredentials: null` when unauthenticated. +- Admin routes require `ADMIN_SECRET` bearer or an admin session (`role=admin`); DB mode only. +- `GET /api/env` in DB mode requires an authenticated session and only returns decrypted credentials when a password or derived key is available on the session. +- Env writes in DB mode require admin authorization (admin session or `ADMIN_SECRET` bearer); in headless they require `API_KEY` or Basic Auth. + +## OpenAPI-Modeled Endpoints +For request/response schemas, examples, and auth security schemes, use `docs/openapi/openapi.yaml`. + +Authentication +- `GET /api/auth/status` +- `POST /api/auth/login` +- `POST /api/auth/logout` + +Status and Updates +- `GET /api/status` + +Events +- `GET /api/events` (WebSocket upgrade) + +Configuration (env + shares) +- `GET /api/env` +- `POST /api/env` +- `POST /api/env/delete` +- `GET /api/env/shares` (headless-only; 404 in DB mode) +- `POST /api/env/shares` (headless-only; 404 in DB mode) + +Peers +- `GET /api/peers` +- `GET /api/peers/group` +- `GET /api/peers/self` +- `POST /api/peers/ping` + +Recovery +- `POST /api/recover` +- `POST /api/recover/validate` + +Crypto +- `POST /api/sign` +- `POST /api/nip44/encrypt` +- `POST /api/nip44/decrypt` +- `POST /api/nip04/encrypt` +- `POST /api/nip04/decrypt` + +NIP-46 (DB only) +- `GET /api/nip46/sessions` +- `POST /api/nip46/sessions` +- `PUT /api/nip46/sessions/{pubkey}/policy` +- `PUT /api/nip46/sessions/{pubkey}/status` +- `DELETE /api/nip46/sessions/{pubkey}` +- `GET /api/nip46/history` + +Admin (DB only) +- `GET /api/admin/api-keys` +- `POST /api/admin/api-keys` +- `POST /api/admin/api-keys/revoke` +- `GET /api/admin/users` +- `POST /api/admin/users/delete` +- `GET /api/admin/whoami` + +User (DB only, session auth only) +- `GET /api/user/profile` +- `GET /api/user/credentials` +- `POST /api/user/credentials` +- `PUT /api/user/credentials` +- `DELETE /api/user/credentials` +- `GET /api/user/relays` +- `POST /api/user/relays` +- `PUT /api/user/relays` + +Onboarding (DB only; unauthenticated) +- `GET /api/onboarding/status` +- `POST /api/onboarding/validate-admin` +- `POST /api/onboarding/setup` + +## Endpoints Not Modeled In OpenAPI (Highest Priority Gaps) +These are active routes that are not captured in `docs/openapi/openapi.yaml` and should be documented there or in a companion spec. + +Documentation UI +- `/api/docs` (Swagger UI), `/api/docs/openapi.json`, `/api/docs/openapi.yaml`, `/api/docs/assets/*`. +- In production, `/api/docs` requires auth if `AUTH_ENABLED=true`. + +Update checks +- `GET /api/update` checks GitHub releases/tags and is disabled for managed deployments or when `UPDATE_CHECK_DISABLED=true`. + +Admin utilities +- `GET /api/admin/status` (DB-only; initialization/status info). + +Environment secret reveal +- `POST /api/env/admin-secret` (DB-only; requires admin session; confirm flag required). + +Peer policy management +- `GET /api/peers/policies` (list policy summaries). +- `GET /api/peers/{pubkey}/policy` (single policy read). +- `PUT /api/peers/{pubkey}/policy` (set allowSend/allowReceive; persists to DB when possible, fallback store otherwise). + +NIP-46 extended API (DB only) +- `GET /api/nip46/transport` and `PUT /api/nip46/transport` (transport key). +- `GET /api/nip46/relays`, `POST /api/nip46/relays`, `PUT /api/nip46/relays` (relay pool management). +- `GET /api/nip46/requests`, `POST /api/nip46/requests`, `DELETE /api/nip46/requests` (request queue). +- `POST /api/nip46/connect` (process `nostrconnect://` URI). + +Non-API WebSocket +- `GET /` with `Upgrade: websocket` is the internal relay WebSocket; origin and rate limits apply. + +## Timeouts, Rate Limits, and Errors +- Crypto timeouts: `/api/sign`, `/api/nip44/*`, `/api/nip04/*` honor `FROSTR_SIGN_TIMEOUT` (preferred) or `SIGN_TIMEOUT_MS` (default 30000ms; bounds 1000..120000ms). +- Rate limits: auth, env writes, recovery, and WebSocket upgrades return 429 with `Retry-After`. +- Error payloads vary by endpoint. OpenAPI `ErrorResponse` is `{ error, success?: false }`; unhandled errors can return `{ code, error, requestId }` with `X-Request-ID`. +- Many endpoints enforce a JSON body size limit of `DEFAULT_MAX_JSON_BODY` (64KB). + +## Practical Guidance for LLM Use +- Treat `docs/openapi/openapi.yaml` as the canonical schema and validate against route code for endpoints listed under "Not Modeled". +- When describing endpoint behavior, include mode constraints (DB vs headless) and auth method requirements. +- For WebSocket `/api/events`, describe the auth handshake and message envelope; avoid inventing event `type` values. diff --git a/llm/context/ENVIRONMENT_VARIABLES.md b/llm/context/ENVIRONMENT_VARIABLES.md index 88bc8cf..af5fd98 100644 --- a/llm/context/ENVIRONMENT_VARIABLES.md +++ b/llm/context/ENVIRONMENT_VARIABLES.md @@ -1,5 +1,7 @@ # Igloo Server Environment Variables Reference +Last verified: 2026-02-09 + ## ⚠️ CRITICAL SECURITY NOTE **SESSION_SECRET must NEVER be exposed via any API endpoint**. It is strictly server-only and is explicitly excluded from: @@ -82,11 +84,39 @@ Igloo Server operates in two distinct modes with different environment variable | `NIP46_SESSION_RATE_LIMIT_MAX` | NIP-46 session create max | Headless: `30`, Database: `120` | Mode-dependent | Applies to `/api/nip46/sessions` | | `NIP46_SESSION_RATE_LIMIT_WINDOW` | NIP-46 session rate limit window (seconds) | Identical behavior | `3600` | Applies to `/api/nip46/sessions` | +### WebSocket Upgrade Abuse Protection + +| Variable | Purpose | Both Modes Usage | Default | Notes | Source | +|----------|---------|------------------|---------|-------|--------| +| `RATE_LIMIT_WS_UPGRADE_WINDOW` | WebSocket upgrade limiter window (seconds) | Identical behavior | Falls back to `RATE_LIMIT_WINDOW` (`900`) | Applies to `/api/events` and `/` WebSocket upgrades | `src/server.ts` | +| `RATE_LIMIT_WS_UPGRADE_MAX` | WebSocket upgrade limiter max attempts per window | Identical behavior | `30` | Applies to `/api/events` and `/` WebSocket upgrades | `src/server.ts` | +| `WS_MAX_CONNECTIONS_PER_IP` | Max concurrent WebSocket connections per IP | Identical behavior | `5` | Applies to `/api/events` and `/` WebSocket upgrades | `src/server.ts` | +| `WS_MSG_RATE` | WebSocket message rate limit (tokens/sec) | Identical behavior | `20` | Burst is controlled by `WS_MSG_BURST` | `src/server.ts` | +| `WS_MSG_BURST` | WebSocket message burst capacity (tokens) | Identical behavior | `40` (min `WS_MSG_RATE`) | Token bucket capacity | `src/server.ts` | + +### Update Checks + +| Variable | Purpose | Both Modes Usage | Default | Notes | Source | +|----------|---------|------------------|---------|-------|--------| +| `UPDATE_CHECK_DISABLED` | Disable update checks | Identical behavior | `false` | When true, `GET /api/update` is disabled | `src/routes/update.ts` | +| `MANAGED_DEPLOYMENT` | Mark deployment as managed | Identical behavior | `false` | Also treated as managed when `HEADLESS=true` or `SKIP_ADMIN_SECRET_VALIDATION=true` | `src/routes/update.ts` | +| `UPDATE_CHECK_TIMEOUT_MS` | Timeout for upstream update check (ms) | Identical behavior | `5000` | Aborts upstream request | `src/routes/update.ts` | +| `UPDATE_CHECK_TTL_MS` | Cache TTL for successful update checks (ms) | Identical behavior | `21600000` | 6 hours | `src/routes/update.ts` | +| `UPDATE_CHECK_FAILURE_TTL_MS` | Cache TTL after failed update checks (ms) | Identical behavior | `900000` | 15 minutes | `src/routes/update.ts` | +| `APP_VERSION` | Override app version reported by `/api/update` | Identical behavior | Unset | Intended for packaged builds | `src/routes/update.ts` | +| `GITHUB_TOKEN` | Token for GitHub API requests | Identical behavior | Unset | Used to avoid rate limits when checking releases | `src/routes/update.ts` | + ### CORS Security -| Variable | Purpose | Both Modes Usage | Default | Security Warning | -|----------|---------|------------------|---------|------------------| -| `ALLOWED_ORIGINS` | CORS allowed origins (CSV) | Identical parsing | `*` | Warns in production if unset (`src/routes/utils.ts`) | +| Variable | Purpose | Both Modes Usage | Default | Security Notes | +|----------|---------|------------------|---------|----------------| +| `ALLOWED_ORIGINS` | Browser origin allowlist (CSV) | Applies to HTTP CORS headers and WebSocket Origin checks | Unset | In production, leaving this unset blocks browser cross-origin HTTP (CORS) and restricts browser WebSockets to same-host; wildcard `*` is rejected for WebSocket upgrades in production (`src/routes/utils.ts`). | + +**Important nuance:** `ALLOWED_ORIGINS` is used by two different mechanisms with different semantics: +- **HTTP CORS** uses exact origin matching (or `*`) and does **not** understand `@self`. +- **WebSocket Origin checks** support a special token `@self` (host match, port-agnostic) and explicitly reject `*` in production. + +See “Origin Enforcement (HTTP vs WebSocket)” below for details. ### Node Restart Configuration @@ -102,7 +132,26 @@ Igloo Server operates in two distinct modes with different environment variable | Variable | Purpose | Both Modes Usage | Default | Range | Source | |----------|---------|------------------|---------|-------|--------| | `FROSTR_SIGN_TIMEOUT` | Signing operation timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/routes/utils.ts`, `src/node/manager.ts` | +| `SIGN_TIMEOUT_MS` | Legacy alias for signing timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/routes/utils.ts` | | `CONNECTIVITY_PING_TIMEOUT_MS` | Keepalive ping timeout (ms) | Identical behavior | `10000` | 1000ms - 120000ms | `src/node/manager.ts` | +| `PING_TIMEOUT_MS` | Legacy alias for keepalive ping timeout (ms) | Identical behavior | `10000` | 1000ms - 120000ms | `src/node/manager.ts` | +| `PUBLISH_EVENT_TIMEOUT_MS` | Relay publish receipt timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/node/manager.ts` | +| `RELAY_PUBLISH_TIMEOUT` | Legacy alias for relay publish receipt timeout (ms) | Identical behavior | `30000` | 1000ms - 120000ms | `src/node/manager.ts` | +| `SELF_ECHO_TIMEOUT_MS` | Startup echo timeout (ms) | Identical behavior | `10000` | 1000ms - 60000ms | `src/node/manager.ts` | +| `ECHO_TIMEOUT_MS` | Legacy alias for startup echo timeout (ms) | Identical behavior | `10000` | 1000ms - 60000ms | `src/node/manager.ts` | + +### Relay Probing & Startup Performance + +| Variable | Purpose | Both Modes Usage | Default | Notes | Source | +|----------|---------|------------------|---------|-------|--------| +| `SKIP_RELAY_PROBE` | Skip relay probing during node creation | Identical behavior | `false` | Faster startup; uses relays without testing support | `src/const.ts` | +| `DEFER_RELAY_PROBE` | Defer relay probing to background | Identical behavior | `false` | Ignored when `SKIP_RELAY_PROBE=true` | `src/const.ts`, `src/node/manager.ts` | +| `SKIP_STARTUP_ECHO` | Skip headless startup echo broadcasts | Identical behavior | `false` | Perf option for cold start | `src/const.ts` | +| `INITIAL_CONNECTIVITY_DELAY` | Delay before initial connectivity check (ms) | Identical behavior | `5000` | Used during node creation; invalid values fall back to default | `src/node/manager.ts` | +| `MAX_PEER_STATUS_ENTRIES` | Bound peer status memory (FIFO eviction) | Identical behavior | `1000` | Prevents unbounded growth in long-running servers | `src/const.ts` | +| `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` | Swallow benign relay publish errors | Identical behavior | `true` | Alias: `RELAY_ALLOW_BENIGN_SWALLOW` | `src/node/manager.ts` | +| `RELAY_ALLOW_BENIGN_SWALLOW` | Legacy alias for benign publish swallow | Identical behavior | `true` | Prefer `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` | `src/node/manager.ts` | +| `NODE_PUBLISH_METRICS` | Enable relay publish failure metrics | Identical behavior | `true` (DB mode), `false` (headless) | Set to `false` to disable; defaults off in headless mode | `src/node/manager.ts` | ### Error Circuit Breaker @@ -118,7 +167,17 @@ Igloo Server operates in two distinct modes with different environment variable |----------|---------|------------------|---------|--------| | `TRUST_PROXY` | Trust proxy headers for client IP | Identical behavior | `false` | `src/routes/utils.ts` | -When `TRUST_PROXY=true`, the server trusts these headers (in order): `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`. Required for accurate rate limiting behind reverse proxies. +When `TRUST_PROXY=true`, the server trusts these headers (in order): `X-Forwarded-For`, `X-Real-IP`, `CF-Connecting-IP`. This is required for accurate rate limiting behind reverse proxies. + +`TRUST_PROXY=true` also affects WebSocket Origin enforcement: the server will prefer `X-Forwarded-Host` (when present) over `Host` when evaluating “same-host” and `@self` Origin matches for browser WebSockets (`src/routes/utils.ts`). + +### Onboarding Hardening (Database Mode Only) + +| Variable | Purpose | Both Modes Usage | Default | Notes | Source | +|----------|---------|------------------|---------|-------|--------| +| `FINGERPRINT_SECRET` | Secret salt for stable per-client identifiers | DB mode only | Unset | Improves stability across restarts; leave unset to use best-effort fallback | `src/routes/onboarding.ts` | +| `CLIENT_ID_TTL_MS` | TTL for client-id cache entries (ms) | DB mode only | `86400000` | Clamped to 10m..7d | `src/routes/onboarding.ts` | +| `LOG_FINGERPRINT_FALLBACK` | Log fingerprint fallback details | DB mode only | `false` | Use only for troubleshooting | `src/routes/onboarding.ts` | ### System Environment @@ -132,6 +191,14 @@ When `TRUST_PROXY=true`, the server trusts these headers (in order): `X-Forwarde |----------|---------|---------------|---------------|--------| | `CREDENTIALS_SAVED_AT` | Timestamp marker | Set when env creds detected | Set when DB creds saved | Tracks credential freshness | +### Managed Installs & CI (Advanced) + +| Variable | Purpose | Both Modes Usage | Default | Notes | Source | +|----------|---------|------------------|---------|-------|--------| +| `SKIP_ADMIN_SECRET_VALIDATION` | Skip onboarding "enter admin secret" step | DB mode only | `false` | Umbrel-style managed installs only; requires `ADMIN_SECRET` to be set out-of-band | `src/const.ts`, `src/routes/onboarding.ts` | +| `AUTO_ADMIN_SECRET` | Auto-generate ephemeral `ADMIN_SECRET` | DB mode only | `false` | Also enabled when `CI=true` or `NODE_ENV=test`; non-production only | `src/const.ts` | +| `CI` | Signals CI environment | DB mode only | Unset | When `CI=true`, enables `AUTO_ADMIN_SECRET` behavior | `src/const.ts` | + ## Critical Security & Functional Differences ### 1. Credential Storage Architecture @@ -339,15 +406,54 @@ Notes: - On Windows, chmod and directory fsync are best-effort; warnings are logged. - In production, a missing/invalid secret that cannot be generated will terminate the process. -### 3. CORS Security Warnings +### 3. Origin Enforcement (HTTP vs WebSocket) -Production security warning (`src/routes/utils.ts`): +Igloo Server enforces browser-origin policies in two layers: +- **HTTP CORS headers**: controls whether browsers allow JavaScript to read HTTP responses cross-origin. +- **WebSocket Origin checks**: controls whether browser WebSocket handshakes are accepted based on the `Origin` header. + +These layers intentionally behave differently to avoid accidental production exposure while still supporting “same host” LAN/IP/onion access patterns. + +### HTTP CORS behavior (`getSecureCorsHeaders`, `src/routes/utils.ts`) +- If `ALLOWED_ORIGINS` is **unset**: + - In **development** (`NODE_ENV` is not `production`): responds with `Access-Control-Allow-Origin: *`. + - In **production**: does **not** set `Access-Control-Allow-Origin` (so browsers block cross-origin reads). +- If `ALLOWED_ORIGINS` is **set** (comma-separated): + - If it contains `*`: responds with `Access-Control-Allow-Origin: *`. + - Else, if the request’s `Origin` exactly matches one of the configured origins: reflects that origin and sets `Vary: Origin`. + - Otherwise: no CORS header is set (browser blocks). + +Notes: +- Origins must be exact strings like `https://example.com` (include scheme, and include `:port` when non-default). +- `@self` is **not** interpreted for HTTP CORS. + +### WebSocket Origin behavior (`isWebSocketOriginAllowed`, `src/routes/utils.ts`) +- If there is **no** `Origin` header: allowed (common for non-browser clients). +- If `ALLOWED_ORIGINS` is **unset/empty**: + - In **development**: allowed. + - In **production**: allowed only when `Origin` hostname matches the request hostname (“same-host”); otherwise rejected. +- If `ALLOWED_ORIGINS` is **set**: + - Special token `@self` allows any `Origin` whose hostname matches the request hostname (ports may differ). + - In **production**, `*` is explicitly rejected for WebSocket upgrades. + - Otherwise, the `Origin` must match one of the configured allowed origins exactly. + +### Practical guidance +- If your UI and API are served from the same public origin via a reverse proxy (recommended), you typically do not need cross-origin HTTP CORS, but you should still set `ALLOWED_ORIGINS` in production to avoid repeated security errors and to make intent explicit. +- If your UI is on one origin and the API/WS is on another (different host or port), you must set `ALLOWED_ORIGINS` to include the UI origin(s). For browser WebSockets with a host mismatch, either list the exact origins or include `@self` when you want “whatever host the user connected through” semantics. + +### Production messaging (`src/routes/utils.ts`) +When `ALLOWED_ORIGINS` is unset in production, the server logs a security error and intentionally omits CORS headers so browsers will block cross-origin reads. + +#### Historical note + +The behavior below is the current, correct production posture. Older documentation that implied “wildcard CORS in production when unset” is outdated and should not be relied on. + +Current production behavior (`src/routes/utils.ts`): ```typescript -if (!allowedOriginsEnv) { - headers['Access-Control-Allow-Origin'] = '*'; - if (process.env.NODE_ENV === 'production') { - console.warn('SECURITY WARNING: ALLOWED_ORIGINS not configured in production. Using wildcard (*) for CORS.'); - } +if (!allowedOriginsEnv && process.env.NODE_ENV === 'production') { + // SECURITY: Block browser cross-origin reads in production unless explicitly configured. + // Intentionally do not set Access-Control-Allow-Origin. + console.error('SECURITY ERROR: ALLOWED_ORIGINS must be configured in production. CORS requests will be blocked.'); } ``` diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index ca9e96c..6ecb56a 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -29,7 +29,7 @@ Key files and current state: - Image pinned to a digest (current): - `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:537a21c960402f12e2157432ca91573d6155a1c17ef88a4a07e42bd839867d2f` - `APP_DATA_DIR` is mounted to `/app/data`. - - `ALLOWED_ORIGINS` default includes `@self` plus `umbrel.local` variants. + - `ALLOWED_ORIGINS` default includes `@self` plus `umbrel.local` variants (notably for browser WebSocket Origin checks). - App proxy is defined in `umbrel-app.yml` via an `app_proxy` block that points to the Igloo service/port. There is no `PROXY_AUTH_WHITELIST` env. ## Umbrel Image Implementation @@ -55,7 +55,7 @@ These values are set in the store compose and expected by the UI flow: - `TRUST_PROXY=true` for Umbrel app proxy headers. - `DB_PATH=/app/data/igloo.db` (database mode). - `HEADLESS=false` to serve UI assets. -- `ALLOWED_ORIGINS` defaults to `@self` plus `umbrel.local` variants; `@self` auto-allows the host users connect through. +- `ALLOWED_ORIGINS` defaults to `@self` plus `umbrel.local` variants; `@self` auto-allows the host users connect through for browser WebSocket Origin enforcement (host match, port-agnostic). ## Umbrel UI and Exports - First run goes straight to account creation. The first user becomes admin. diff --git a/llm/workflows/UMBREL_DEPLOYMENT.md b/llm/workflows/UMBREL_DEPLOYMENT.md index 8a5232c..7a71adf 100644 --- a/llm/workflows/UMBREL_DEPLOYMENT.md +++ b/llm/workflows/UMBREL_DEPLOYMENT.md @@ -144,7 +144,7 @@ Server-side rate limiting still applies even when this flag is enabled. --- ## 7. Troubleshooting -- **CORS/WS blocked (403 on /api/events)**: `ALLOWED_ORIGINS` supports `@self` to auto-allow the host the user connects through (LAN IP, Tor onion, custom domain), ignoring port differences (UI on 80, API on 8002). Default bundle: `ALLOWED_ORIGINS=@self,http://umbrel.local`. Add more if fronting on another host. +- **WS blocked (403 on /api/events)**: WebSocket Origin checks support `@self` to auto-allow the host the user connects through (LAN IP, Tor onion, custom domain), ignoring port differences (UI on 80, WS/API on 8002). Default bundle: `ALLOWED_ORIGINS=@self,http://umbrel.local`. If you are doing browser HTTP requests cross-origin (different host/port), you must also include the exact UI origin(s) in `ALLOWED_ORIGINS`. - **Database write errors**: confirm the container runs as UID/GID 1000 and `/app/data` is writable (non-root user baked into the image). - **Session failures**: check `/app/data/.session-secret`. If missing, perms might be wrong; restart container and ensure Umbrel’s volume owner matches the igloo user. - **Proxy auth / 401 after Umbrel login**: Umbrel’s app proxy enforces the user’s Umbrel session; there is no `PROXY_AUTH_WHITELIST` env. Make sure `umbrel-app.yml` has an `app_proxy` block pointing to the Igloo service/port (default 8002) and that the app is started from the Umbrel dashboard so the proxy route is registered. For Tor/clearnet issues, restart the app to refresh routes and verify `ALLOWED_ORIGINS` includes the hostname you’re using. From 8f33c2c3d467498da83ee9bfe390d9cd87f8ab9f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 15:44:18 -0600 Subject: [PATCH 10/69] nitpicks --- CONTRIBUTING.md | 2 +- compose.yml | 33 +++-- docs/CONFIG.md | 6 +- docs/DEPLOY.md | 2 + llm/context/ENVIRONMENT_VARIABLES.md | 140 +++++++++++++++--- .../credential-storage-implementation.md | 10 +- llm/implementation/umbrel-implementation.md | 1 - 7 files changed, 161 insertions(+), 33 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ec0062..186b357 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -62,7 +62,7 @@ The repo uses a standard `Dockerfile` at the repo root: docker build -t igloo-server . ``` -If you use `compose.yml`, note it uses `env_file: .env` to inject environment variables but does not mount that file into the container. For headless deployments that write config via `/api/env`, mount `.env` as a volume if you expect it to persist (see `docs/CONFIG.md`). +If you use `compose.yml`, note it uses `env_file: .env` to inject environment variables and also mounts `.env` as a read-write volume (via `env_file: .env` plus a volume mount). For headless deployments that write config via `/api/env`, the `.env` file will persist if mounted as a volume (see `docs/CONFIG.md`). ## Coding Standards diff --git a/compose.yml b/compose.yml index b7c7c3d..d60a985 100644 --- a/compose.yml +++ b/compose.yml @@ -1,36 +1,48 @@ services: - igloo-server: build: context: ./ dockerfile: Dockerfile - env_file : .env - image : igloo-server + + # NOTE: `env_file: .env` injects values only at container start. If the app writes to `/app/.env` + # (via `/api/env`), those changes will not affect the running container's environment until restart. + # Also ensure `./.env` exists as a FILE before starting; otherwise Docker may create a directory at + # `./.env` when processing the bind-mount below, which will break both `env_file` and app reads. + env_file: .env + + image: igloo-server environment: - HOST_NAME=0.0.0.0 - HOST_PORT=8002 - NODE_ENV=production # Explicit DB path inside the container; lives under /app/data - DB_PATH=/app/data/igloo.db - container_name : igloo-server - platform : linux/x86_64 - hostname : igloo-server - restart : unless-stopped - init : true - tty : true + + container_name: igloo-server + platform: linux/x86_64 + hostname: igloo-server + restart: unless-stopped + init: true + tty: true + healthcheck: test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8002/api/status || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 10s + networks: - infranet + ports: - "8002:8002" + volumes: - ./src:/app/src:rw - # Mount .env so /api/env writes persist back to the host file (env_file only injects values). + # Mount .env so `/api/env` writes persist back to the host file. + # Guard: create it first (`cp env.example .env` or `touch .env`) to avoid Docker creating `./.env/` as a directory. + # Reminder: changes written here won't affect the running container's env until restart (see env_file note above). - ./.env:/app/.env:rw # Persist database and session secrets between container recreations - ./data:/app/data:rw @@ -38,3 +50,4 @@ services: networks: infranet: driver: bridge + diff --git a/docs/CONFIG.md b/docs/CONFIG.md index c317cd1..05b19eb 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -77,6 +77,8 @@ Related docs: Important persistence/precedence detail: - For keys managed by `/api/env`, Igloo reads from a local `.env` file (relative to the server working directory) and treats it as higher precedence than process environment variables for those keys. - If you deploy with Docker Compose `env_file: .env`, note that this sets container environment variables but does not mount the file into the container. In headless deployments, UI/API changes that write `.env` will not persist across container recreation unless you also mount a volume for the `.env` file (or you manage configuration exclusively via container environment variables and avoid writing via `/api/env`). +- If you bind-mount `./.env:/app/.env` to persist `/api/env` writes, ensure `./.env` exists as a file before starting the container (for example `cp env.example .env` or `touch .env`). If it is missing, Docker may create `./.env/` as a directory at mount time, which will break both `env_file: .env` and the app's `.env` reads/writes. +- `env_file: .env` is only read at container start. Changes written to the mounted `/app/.env` by the app will not affect the running container's environment until you restart the container. ## Operational Tuning Knobs (Most Commonly Missed) @@ -123,4 +125,6 @@ Performance toggles (advanced): In both cases, Igloo stores: - SQLite at `/igloo.db` when `DB_PATH` is a directory (or uses the file path directly when it looks like a file) -- Session secret at the inferred directory `/.session-secret` +- Session secret in the `DB_PATH` directory as `.session-secret`: + - If `DB_PATH` is a directory: `/.session-secret` + - If `DB_PATH` is a file path: the directory containing `DB_PATH` plus `/.session-secret` (for example, `/var/lib/igloo/.session-secret` when `DB_PATH=/var/lib/igloo/igloo.db`) diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 9032763..00acb3f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -58,6 +58,8 @@ services: ``` Start with `docker compose up -d` after creating and editing `.env` (copy from `.env.example`). +Note: `env_file: .env` injects values only at container start. If you also bind-mount `./.env:/app/.env` to persist `/api/env` writes, ensure `./.env` exists as a file first (`cp env.example .env` or `touch .env`) or Docker may create a directory at `./.env/`. + 4) Firewall (UFW): ```bash sudo ufw allow 80 443 22 diff --git a/llm/context/ENVIRONMENT_VARIABLES.md b/llm/context/ENVIRONMENT_VARIABLES.md index af5fd98..89770dd 100644 --- a/llm/context/ENVIRONMENT_VARIABLES.md +++ b/llm/context/ENVIRONMENT_VARIABLES.md @@ -378,33 +378,137 @@ const parseRestartConfig = () => { SESSION_SECRET auto-generation (`src/routes/auth.ts`): ```typescript function loadOrGenerateSessionSecret(): string | null { - if (!existsSync(SESSION_SECRET_DIR)) { - mkdirSync(SESSION_SECRET_DIR, { recursive: true, mode: 0o700 }); - } - chmodSync(SESSION_SECRET_DIR, 0o700); + try { + // Ensure data directory exists with strict permissions + if (!existsSync(SESSION_SECRET_DIR)) { + mkdirSync(SESSION_SECRET_DIR, { recursive: true, mode: 0o700 }); + } + // Enforce strict permissions on the directory (0700) + try { + chmodSync(SESSION_SECRET_DIR, 0o700); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + // On Windows, chmod may be a no-op or limited; proceed best-effort + console.warn('⚠️ Windows platform: Unable to enforce 0700 on session secret directory.'); + } - if (existsSync(SESSION_SECRET_FILE)) { - const secret = readFileSync(SESSION_SECRET_FILE, 'utf-8').trim(); - if (/^[0-9a-f]{64}$/i.test(secret)) return secret; - } + // Check if secret already exists + if (existsSync(SESSION_SECRET_FILE)) { + const secret = readFileSync(SESSION_SECRET_FILE, 'utf-8').trim(); + // Validate format: must be exactly 64 hex characters (32 bytes) + if (/^[0-9a-f]{64}$/i.test(secret)) { + console.log('🔑 SESSION_SECRET loaded from secure storage'); + return secret; + } + console.warn('⚠️ Existing SESSION_SECRET is invalid format, generating new one'); + } - const newSecret = randomBytes(32).toString('hex'); - const tempFileName = `.session-secret.tmp.${process.pid}.${randomBytes(8).toString('hex')}`; - const tempFilePath = path.join(SESSION_SECRET_DIR, tempFileName); + // Generate new secret (32 bytes = 64 hex characters) + const newSecret = randomBytes(32).toString('hex'); + + // Atomically write the new secret with unique temp file + const tempFileName = `.session-secret.tmp.${process.pid}.${randomBytes(8).toString('hex')}`; + const tempFilePath = path.join(SESSION_SECRET_DIR, tempFileName); + let tempFileHandle: number | undefined; + let dirHandle: number | undefined; + + try { + // Open temp file with exclusive flag to prevent races + tempFileHandle = openSync(tempFilePath, 'wx', 0o600); + // Write the secret to the temp file + writeSync(tempFileHandle, newSecret, 0, 'utf8'); + + // Open directory handle for fsync + try { + dirHandle = openSync(SESSION_SECRET_DIR, 'r'); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + // Windows doesn't support opening directories for fsync + console.warn('⚠️ Windows platform detected: Directory fsync not available. Session secret may be lost in case of system crash before filesystem cache flush.'); + dirHandle = undefined; + } + + // First, ensure the temp file is on disk + fsyncSync(tempFileHandle); + + // Atomically rename the temp file to the final destination + renameSync(tempFilePath, SESSION_SECRET_FILE); + + // Enforce strict permissions on the final secret file (0600) + try { + chmodSync(SESSION_SECRET_FILE, 0o600); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + console.warn('⚠️ Windows platform: Unable to enforce 0600 on session secret file.'); + } + + // Finally, fsync the directory to durably record the rename (best-effort on Windows) + if (dirHandle !== undefined) { + try { + fsyncSync(dirHandle); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + // Windows fsync on directory handle may fail even after successful open + console.warn('⚠️ Windows platform: Directory fsync failed. Session secret rename may not be durable until filesystem cache flush.'); + } + } + + } catch (error) { + console.error('Failed to write session secret atomically:', error); + // Clean up the temporary file if it exists + if (existsSync(tempFilePath)) { + try { + unlinkSync(tempFilePath); + } catch (cleanupError) { + console.error('Failed to clean up temporary session secret file:', cleanupError); + } + } + throw error; // Re-throw the original error + } finally { + if (tempFileHandle !== undefined) closeSync(tempFileHandle); + if (dirHandle !== undefined) closeSync(dirHandle); + } - const fd = openSync(tempFilePath, 'wx', 0o600); - writeSync(fd, newSecret, 0, 'utf8'); - fsyncSync(fd); - renameSync(tempFilePath, SESSION_SECRET_FILE); - chmodSync(SESSION_SECRET_FILE, 0o600); + // Final assurance of correct permissions after write/rename + try { + chmodSync(SESSION_SECRET_DIR, 0o700); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + console.warn('⚠️ Windows platform: Unable to enforce 0700 on session secret directory.'); + } + try { + chmodSync(SESSION_SECRET_FILE, 0o600); + } catch (e) { + if (process.platform !== 'win32') { + throw e; + } + console.warn('⚠️ Windows platform: Unable to enforce 0600 on session secret file.'); + } - process.env.SESSION_SECRET = newSecret; - return newSecret; + console.log('✨ SESSION_SECRET auto-generated and saved to secure storage'); + console.log(' Sessions will now persist across server restarts'); + + return newSecret; + } catch (error) { + console.error('Failed to load/generate SESSION_SECRET:', error); + return null; + } } ``` Notes: - On Windows, chmod and directory fsync are best-effort; warnings are logged. - In production, a missing/invalid secret that cannot be generated will terminate the process. +- `loadOrGenerateSessionSecret()` does not mutate `process.env.SESSION_SECRET` directly; `validateSessionSecret()` sets `process.env.SESSION_SECRET = generatedSecret` only after successful load/generation. ### 3. Origin Enforcement (HTTP vs WebSocket) diff --git a/llm/implementation/credential-storage-implementation.md b/llm/implementation/credential-storage-implementation.md index a3ab1f0..3f5827d 100644 --- a/llm/implementation/credential-storage-implementation.md +++ b/llm/implementation/credential-storage-implementation.md @@ -34,8 +34,14 @@ Defined in `src/config/crypto.ts`: ## Key Derivation - For password-based operations, `deriveKey(password, user.salt)` uses PBKDF2 and returns 32 bytes. -- The derived key is hex-encoded and used directly as the AES-256-GCM key. -- Derived keys can also be passed directly as 32-byte binary or 64-char hex for session-based flows. +- The derived key is a 32-byte binary value. When it is represented as hex (64 chars), that is only for transient in-memory session storage or ephemeral transfer (e.g., session objects, network transport)—not for database persistence. +- The encryption layer uses a 64-char hex *string* as its input format, but it decodes it back to the original 32-byte key before use: + - `keyHex` (64 chars) -> `Buffer.from(keyHex, 'hex')` (32 bytes) -> `createCipheriv('aes-256-gcm', keyBytes, iv)` + **Derived keys are never written to the database.** In this document, "storage" of derived keys means only in-memory session storage or ephemeral transport mechanisms. +- Session flows may supply derived keys as either: + - raw 32-byte binary (`Uint8Array` / `ArrayBuffer`), or + - 64-char hex string + In both cases, the implementation normalizes to the same 32-byte key bytes before passing it to AES-256-GCM. ## Ciphertext Format - AES-256-GCM encrypts with a random 12-byte IV per operation. diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 6ecb56a..903f794 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -13,7 +13,6 @@ This document captures the working, released Umbrel packaging for Igloo Server. - The Umbrel dev workflow `.github/workflows/umbrel-dev.yml` builds and smoke-tests a local image only; it does not push. ## Umbrel Community Store Repo (Igloo Server Store) -Local path: `/Users/plebdev/Desktop/code/igloo-server-store` Upstream repo: `https://github.com/frostr-org/igloo-server-store` Key files and current state: From 59e3a7dd30ad69e68f88dbb6badde3a8bd0e6b7d Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 16:55:16 -0600 Subject: [PATCH 11/69] persist UI event logs to sqlite with pagination, lazy-load blobs, and export --- frontend/components/EventLog.tsx | 23 +- frontend/components/Signer.tsx | 188 ++++++--- frontend/components/ui/UpdateBanner.tsx | 2 +- frontend/components/ui/event-log.tsx | 45 +- frontend/components/ui/log-entry.tsx | 63 ++- .../migrations/20260209_0001_ui_event_log.sql | 29 ++ src/db/nip46.ts | 28 +- src/db/ui-event-log.test.ts | 84 ++++ src/db/ui-event-log.ts | 396 ++++++++++++++++++ src/node/manager.ts | 79 ++-- src/routes/event-log.ts | 168 ++++++++ src/routes/index.ts | 3 + src/routes/nip46.ts | 21 +- src/routes/update.ts | 1 + src/server.ts | 22 +- tests/routes/event-log.spec.ts | 33 ++ 16 files changed, 1076 insertions(+), 109 deletions(-) create mode 100644 src/db/migrations/20260209_0001_ui_event_log.sql create mode 100644 src/db/ui-event-log.test.ts create mode 100644 src/db/ui-event-log.ts create mode 100644 src/routes/event-log.ts create mode 100644 tests/routes/event-log.spec.ts diff --git a/frontend/components/EventLog.tsx b/frontend/components/EventLog.tsx index 6229ffc..b600ffc 100644 --- a/frontend/components/EventLog.tsx +++ b/frontend/components/EventLog.tsx @@ -8,18 +8,39 @@ export interface EventLogProps { logs: LogEntryData[]; isSignerRunning: boolean; onClearLogs: () => void; + onDownload?: () => void; + downloading?: boolean; hideHeader?: boolean; autoExpandTypes?: string[]; + onLoadOlder?: () => void; + hasMore?: boolean; + loadingOlder?: boolean; } -export const EventLog: React.FC = ({ logs, isSignerRunning, onClearLogs, hideHeader, autoExpandTypes }) => { +export const EventLog: React.FC = ({ + logs, + isSignerRunning, + onClearLogs, + onDownload, + downloading, + hideHeader, + autoExpandTypes, + onLoadOlder, + hasMore, + loadingOlder +}) => { return ( ); }; diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index ee0e31e..9107fa9 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -44,12 +44,11 @@ const pulseStyle = ` `; const DEFAULT_RELAY = "wss://relay.primal.net"; -const LOG_STORAGE_KEY = "igloo:event-log"; -const MAX_LOG_ENTRIES = 500; +// UI event log history is persisted server-side in DB mode. +// Keep a bounded in-memory buffer to prevent runaway memory usage; older entries remain queryable. +const MAX_EVENT_LOG_IN_MEMORY = 10000; const AUTO_EXPAND_EVENT_TYPES: string[] = ['sign']; -const canUseSessionStorage = () => typeof window !== "undefined" && typeof window.sessionStorage !== "undefined"; - const sanitizeLogEntry = (entry: unknown): LogEntryData | null => { if (!entry || typeof entry !== "object") return null; const log = entry as Partial; @@ -73,48 +72,16 @@ const sanitizeLogEntry = (entry: unknown): LogEntryData | null => { timestamp, type: log.type, message: log.message, - data: log.data + data: log.data, + // Pass through persistence hints when present. + dataHash: (log as any).dataHash, + dataPreview: (log as any).dataPreview }; }; -const readStoredLogs = (): LogEntryData[] => { - if (!canUseSessionStorage()) return []; - - try { - const raw = window.sessionStorage.getItem(LOG_STORAGE_KEY); - if (!raw) return []; - - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - - return parsed - .map(sanitizeLogEntry) - .filter((entry): entry is LogEntryData => entry !== null) - .slice(-MAX_LOG_ENTRIES); - } catch (error) { - console.warn("Failed to parse stored event logs:", error); - return []; - } -}; - -const writeStoredLogs = (entries: LogEntryData[]) => { - if (!canUseSessionStorage()) return; - - if (entries.length === 0) { - window.sessionStorage.removeItem(LOG_STORAGE_KEY); - return; - } - - try { - window.sessionStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(entries)); - } catch (error) { - console.warn("Failed to persist event logs:", error); - } -}; - -const clearStoredLogs = () => { - if (!canUseSessionStorage()) return; - window.sessionStorage.removeItem(LOG_STORAGE_KEY); +const parseSeq = (id: string): number | null => { + const n = Number.parseInt(id, 10); + return Number.isFinite(n) && n > 0 ? n : null; }; // Reusable deep validation helpers to avoid duplication @@ -210,11 +177,17 @@ const Signer = forwardRef(({ initialData, authHeaders share: false }); const [credentialSaveError, setCredentialSaveError] = useState(null); - const [logs, setLogs] = useState(() => readStoredLogs()); + const [logs, setLogs] = useState([]); + const [oldestSeq, setOldestSeq] = useState(null); + const [hasMoreHistory, setHasMoreHistory] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [downloadingLogs, setDownloadingLogs] = useState(false); const [realSelfPubkey, setRealSelfPubkey] = useState(null); // Reference for compatibility with parent component const nodeRef = useRef(null); + const authHeadersRef = useRef(authHeaders); + useEffect(() => { authHeadersRef.current = authHeaders; }, [authHeaders]); // Expose methods to parent components through ref useImperativeHandle(ref, () => ({ @@ -416,11 +389,12 @@ const Signer = forwardRef(({ initialData, authHeaders const params = new URLSearchParams(); // Check if we have auth headers and convert them to URL params - if (authHeaders['X-API-Key']) { - params.set('apiKey', authHeaders['X-API-Key']); - } else if (authHeaders['X-Session-ID']) { - params.set('sessionId', authHeaders['X-Session-ID']); - } else if (authHeaders['Authorization'] && authHeaders['Authorization'].startsWith('Basic ')) { + const currentAuth = authHeadersRef.current; + if (currentAuth['X-API-Key']) { + params.set('apiKey', currentAuth['X-API-Key']); + } else if (currentAuth['X-Session-ID']) { + params.set('sessionId', currentAuth['X-Session-ID']); + } else if (currentAuth['Authorization'] && currentAuth['Authorization'].startsWith('Basic ')) { // For basic auth, we'll rely on cookies or handle it server-side // The server should accept the connection if the user is already authenticated } @@ -478,8 +452,8 @@ const Signer = forwardRef(({ initialData, authHeaders } const updated = [...prev, nextLog]; - if (updated.length > MAX_LOG_ENTRIES) { - return updated.slice(updated.length - MAX_LOG_ENTRIES); + if (updated.length > MAX_EVENT_LOG_IN_MEMORY) { + return updated.slice(updated.length - MAX_EVENT_LOG_IN_MEMORY); } return updated; }); @@ -545,6 +519,104 @@ const Signer = forwardRef(({ initialData, authHeaders }; }, []); + // Load initial persisted history (DB mode). The realtime WebSocket continues to append new events. + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const res = await fetch('/api/event-log?limit=200', { headers: authHeaders }); + if (!res.ok) { + if (res.status === 401) { + try { window.dispatchEvent(new CustomEvent('authExpired')); } catch {} + } + return; + } + const payload = await res.json(); + const entries: unknown = (payload as any)?.entries; + const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq; + if (!Array.isArray(entries)) return; + const sanitized = entries.map(sanitizeLogEntry).filter((e): e is LogEntryData => e !== null); + const chronological = [...sanitized].reverse(); + if (cancelled) return; + setLogs(prev => { + if (prev.length === 0) return chronological; + const existing = new Set(prev.map(e => e.id)); + const merged = [...chronological.filter(e => !existing.has(e.id)), ...prev]; + return merged; + }); + const seqs = chronological.map(e => parseSeq(e.id)).filter((n): n is number => n !== null); + const minSeq = seqs.length ? Math.min(...seqs) : null; + setOldestSeq(minSeq); + setHasMoreHistory(typeof nextBeforeSeq === 'number' ? nextBeforeSeq > 0 : chronological.length === 200); + } catch { + // Ignore history load errors; realtime stream still works. + } + }; + if (!isHeadlessMode) { + void load(); + } + return () => { cancelled = true; }; + }, [authHeaders, isHeadlessMode]); + + const handleLoadOlder = useCallback(async () => { + if (!oldestSeq || loadingOlder) return; + setLoadingOlder(true); + try { + const res = await fetch(`/api/event-log?limit=200&beforeSeq=${oldestSeq}`, { headers: authHeaders }); + if (!res.ok) { + if (res.status === 401) { + try { window.dispatchEvent(new CustomEvent('authExpired')); } catch {} + } + return; + } + const payload = await res.json(); + const entries: unknown = (payload as any)?.entries; + const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq; + if (!Array.isArray(entries)) return; + const sanitized = entries.map(sanitizeLogEntry).filter((e): e is LogEntryData => e !== null); + const chronological = [...sanitized].reverse(); + setLogs(prev => { + const existing = new Set(prev.map(e => e.id)); + const merged = [...chronological.filter(e => !existing.has(e.id)), ...prev]; + return merged; + }); + const seqs = chronological.map(e => parseSeq(e.id)).filter((n): n is number => n !== null); + const minSeq = seqs.length ? Math.min(...seqs) : oldestSeq; + setOldestSeq(minSeq); + setHasMoreHistory(typeof nextBeforeSeq === 'number' ? nextBeforeSeq > 0 : chronological.length === 200); + } finally { + setLoadingOlder(false); + } + }, [oldestSeq, loadingOlder, authHeaders]); + + const handleDownloadLogs = useCallback(async () => { + if (downloadingLogs) return; + setDownloadingLogs(true); + try { + const res = await fetch('/api/event-log/export', { headers: authHeaders }); + if (!res.ok) { + if (res.status === 401) { + try { window.dispatchEvent(new CustomEvent('authExpired')); } catch {} + } + throw new Error('Failed to export logs'); + } + const blob = await res.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + a.href = url; + a.download = `igloo-event-log-${stamp}.ndjson`; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => window.URL.revokeObjectURL(url), 500); + } catch (error) { + console.warn('Log export failed', error); + } finally { + setDownloadingLogs(false); + } + }, [authHeaders, downloadingLogs]); + // Add effect to cleanup on unmount useEffect(() => { // Cleanup function that runs when component unmounts @@ -929,14 +1001,11 @@ const Signer = forwardRef(({ initialData, authHeaders // Signer is managed by the server - no manual stop needed }; - // Persist logs in session storage while component stays mounted - useEffect(() => { - writeStoredLogs(logs); - }, [logs]); - const handleClearLogs = useCallback(() => { - clearStoredLogs(); + // Audit log is persisted server-side; clearing only resets the current view. setLogs([]); + setOldestSeq(null); + setHasMoreHistory(false); }, []); // Show loading state while fetching environment variables @@ -1254,6 +1323,11 @@ const Signer = forwardRef(({ initialData, authHeaders isSignerRunning={isSignerRunning} onClearLogs={handleClearLogs} autoExpandTypes={AUTO_EXPAND_EVENT_TYPES} + onLoadOlder={handleLoadOlder} + hasMore={hasMoreHistory} + loadingOlder={loadingOlder} + onDownload={handleDownloadLogs} + downloading={downloadingLogs} />
diff --git a/frontend/components/ui/UpdateBanner.tsx b/frontend/components/ui/UpdateBanner.tsx index 481200d..5ce5591 100644 --- a/frontend/components/ui/UpdateBanner.tsx +++ b/frontend/components/ui/UpdateBanner.tsx @@ -10,7 +10,7 @@ interface UpdateBannerProps { } export const UpdateBanner: React.FC = ({ info, className }) => { - if (!info || !info.enabled || !info.updateAvailable || !info.latestVersion) { + if (!info || !info.enabled || !info.updateAvailable || !info.currentVersion || !info.latestVersion) { return null; } diff --git a/frontend/components/ui/event-log.tsx b/frontend/components/ui/event-log.tsx index bf79dd2..2a13a9b 100644 --- a/frontend/components/ui/event-log.tsx +++ b/frontend/components/ui/event-log.tsx @@ -3,7 +3,7 @@ import { IconButton } from "./icon-button"; import { StatusIndicator } from "./status-indicator"; import { Badge } from "./badge"; import { Button } from "./button"; -import { Trash2, ChevronDown, ChevronUp, Filter, X } from "lucide-react"; +import { Trash2, ChevronDown, ChevronUp, Filter, X, Download } from "lucide-react"; import { cn } from "../../lib/utils"; import { LogEntry, type LogEntryData } from "./log-entry"; @@ -11,18 +11,28 @@ interface EventLogProps { logs: LogEntryData[]; isSignerRunning?: boolean; onClearLogs: () => void; + onDownload?: () => void; + downloading?: boolean; title?: string; hideHeader?: boolean; autoExpandTypes?: string[]; + onLoadOlder?: () => void; + hasMore?: boolean; + loadingOlder?: boolean; } export const EventLog = memo(({ logs, isSignerRunning = false, onClearLogs, + onDownload, + downloading = false, title = "Event Log", hideHeader = false, - autoExpandTypes = [] + autoExpandTypes = [], + onLoadOlder, + hasMore = false, + loadingOlder = false }: EventLogProps) => { const logEndRef = useRef(null); const containerRef = useRef(null); @@ -131,6 +141,20 @@ export const EventLog = memo(({ {isExpanded ? "Click to collapse" : "Click to expand"} + {onDownload ? ( + } + onClick={(e) => { + e.stopPropagation(); + onDownload(); + }} + tooltip={downloading ? "Downloading…" : "Download logs"} + disabled={downloading} + className="transition-all duration-200 hover:bg-gray-600/30" + /> + ) : null} )} + + {isExpanded && onLoadOlder ? ( +
+ + + History is persisted server-side in DB mode. Clearing only resets the current view. + +
+ ) : null}
{ const [isMessageExpanded, setIsMessageExpanded] = React.useState(false); - const hasData = log.data && Object.keys(log.data).length > 0; + const [resolvedData, setResolvedData] = React.useState(log.data ?? log.dataPreview); + const [isLoadingData, setIsLoadingData] = React.useState(false); + const [hasFetchedFull, setHasFetchedFull] = React.useState(false); + + React.useEffect(() => { + if (!hasFetchedFull) { + setResolvedData(log.data ?? log.dataPreview); + } + }, [log.data, log.dataPreview, hasFetchedFull]); + + const hasData = !!(resolvedData && (typeof resolvedData !== 'object' || Object.keys(resolvedData).length > 0)) || !!log.dataHash; const handleClick = useCallback(() => { if (hasData) { @@ -79,22 +94,54 @@ export const LogEntry = memo(({ log }: LogEntryProps) => { } }, [hasData]); + React.useEffect(() => { + if (!isMessageExpanded) return; + if (resolvedData !== undefined && resolvedData !== null) return; + const hash = typeof log.dataHash === 'string' ? log.dataHash : null; + if (!hash || !/^[a-f0-9]{64}$/.test(hash)) return; + + let cancelled = false; + setIsLoadingData(true); + fetch(`/api/event-log/blob/${hash}`) + .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to fetch'))) + .then(payload => { + if (cancelled) return; + if (payload && typeof payload === 'object' && 'data' in payload) { + setResolvedData((payload as any).data); + setHasFetchedFull(true); + } + }) + .catch(() => { + if (cancelled) return; + // Keep UI usable even if blob fetch fails. + setResolvedData({ _error: 'failed_to_load_payload', hash }); + }) + .finally(() => { + if (cancelled) return; + setIsLoadingData(false); + }); + + return () => { cancelled = true; }; + }, [isMessageExpanded, resolvedData, log.dataHash]); + const signatureSummary = React.useMemo(() => { - if (log.type !== 'sign' || !log.data) return null; - const session = typeof log.data.session === 'string' ? log.data.session : null; - const eventId = typeof log.data.eventId === 'string' ? log.data.eventId : null; - const kind = typeof log.data.kind === 'number' ? log.data.kind : null; + const data = resolvedData; + if (log.type !== 'sign' || !data) return null; + const session = typeof data.session === 'string' ? data.session : null; + const eventId = typeof data.eventId === 'string' ? data.eventId : null; + const kind = typeof data.kind === 'number' ? data.kind : null; const parts: string[] = []; if (session) parts.push(`session ${truncateMiddle(session)}`); if (kind != null) parts.push(`kind ${kind}`); if (eventId) parts.push(`event ${truncateMiddle(eventId)}`); return parts.length ? parts.join(' · ') : null; - }, [log]); + }, [log.type, resolvedData]); const formattedData = React.useMemo(() => { if (!hasData) return null; - return formatLogData(log.data); - }, [log.data, hasData]); + if (isLoadingData) return 'Loading…'; + return formatLogData(resolvedData); + }, [resolvedData, hasData, isLoadingData]); return (
diff --git a/src/db/migrations/20260209_0001_ui_event_log.sql b/src/db/migrations/20260209_0001_ui_event_log.sql new file mode 100644 index 0000000..a17cbac --- /dev/null +++ b/src/db/migrations/20260209_0001_ui_event_log.sql @@ -0,0 +1,29 @@ +-- UI event log persistence (DB mode) +-- Stores every UI-visible server log entry for backscroll + auditability. +-- Payloads are de-duplicated by sha256 hash to reduce space without losing entries. + +CREATE TABLE IF NOT EXISTS ui_event_log_blobs ( + hash TEXT PRIMARY KEY, + json TEXT NOT NULL, + byte_length INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS ui_event_log_entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at_ms INTEGER NOT NULL, + type TEXT NOT NULL, + message TEXT NOT NULL, + -- Optional payload reference (JSON stored in ui_event_log_blobs) + data_hash TEXT REFERENCES ui_event_log_blobs(hash), + data_preview TEXT, + data_bytes INTEGER, + -- Original event id emitted by the server (pre-persistence). Useful for debugging. + source_id TEXT +); + +CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_created_at_ms ON ui_event_log_entries(created_at_ms DESC); +CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_type_seq ON ui_event_log_entries(type, seq DESC); +CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_seq ON ui_event_log_entries(seq DESC); + diff --git a/src/db/nip46.ts b/src/db/nip46.ts index dd9d80c..cd8c116 100644 --- a/src/db/nip46.ts +++ b/src/db/nip46.ts @@ -338,21 +338,35 @@ export function getNip46RequestById(id: string): Nip46RequestRecord | null { export function listNip46Requests( userId: number | bigint, - opts?: { status?: Nip46RequestStatus[]; limit?: number } + opts?: { + status?: Nip46RequestStatus[] + limit?: number + before?: { createdAt: string; id: string } + } ): Nip46RequestRecord[] { const statuses = opts?.status && opts.status.length ? opts.status : null const limit = Math.min(Math.max(opts?.limit ?? 100, 1), 500) + const clauses: string[] = ['user_id = ?'] + const params: any[] = [userId] + if (statuses) { const placeholders = statuses.map(() => '?').join(',') - const stmt = db.prepare( - `SELECT * FROM nip46_requests WHERE user_id = ? AND status IN (${placeholders}) ORDER BY created_at DESC LIMIT ?` - ) - return stmt.all(userId, ...statuses, limit) as Nip46RequestRecord[] + clauses.push(`status IN (${placeholders})`) + params.push(...statuses) } + + const before = opts?.before + if (before && typeof before.createdAt === 'string' && typeof before.id === 'string' && before.createdAt.trim() && before.id.trim()) { + // Stable cursor using (created_at, id) tuple for deterministic pagination. + clauses.push('(created_at < ? OR (created_at = ? AND id < ?))') + params.push(before.createdAt, before.createdAt, before.id) + } + + const whereSql = `WHERE ${clauses.join(' AND ')}` const stmt = db.prepare( - 'SELECT * FROM nip46_requests WHERE user_id = ? ORDER BY created_at DESC LIMIT ?' + `SELECT * FROM nip46_requests ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ?` ) - return stmt.all(userId, limit) as Nip46RequestRecord[] + return stmt.all(...params, limit) as Nip46RequestRecord[] } export function updateNip46RequestStatus( diff --git a/src/db/ui-event-log.test.ts b/src/db/ui-event-log.test.ts new file mode 100644 index 0000000..d49888e --- /dev/null +++ b/src/db/ui-event-log.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from 'bun:test' +import { Database } from 'bun:sqlite' +import { createUiEventLogStore, ensureUiEventLogSchema } from './ui-event-log.js' + +describe('ui-event-log store', () => { + test('appends entries, paginates by seq, and de-dupes blobs by hash', () => { + const mem = new Database(':memory:') + ensureUiEventLogSchema(mem) + const store = createUiEventLogStore(mem) + + const commonData = { a: 1, b: 'x' } + const e1 = store.append({ type: 'info', message: 'one', data: commonData, timestamp: new Date().toISOString(), id: 'tmp1' }) + const e2 = store.append({ type: 'info', message: 'two', data: commonData, timestamp: new Date().toISOString(), id: 'tmp2' }) + const e3 = store.append({ type: 'error', message: 'three', data: { c: true }, timestamp: new Date().toISOString(), id: 'tmp3' }) + + expect(e1.seq).toBeGreaterThan(0) + expect(e2.seq).toBeGreaterThan(e1.seq) + expect(e3.seq).toBeGreaterThan(e2.seq) + expect(e1.dataHash).toBeTruthy() + expect(e2.dataHash).toBe(e1.dataHash) + + const blobCount = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_blobs').get() as { c: number } + expect(blobCount.c).toBe(2) + + const page1 = store.list({ limit: 2 }) + expect(page1.entries.length).toBe(2) + expect(page1.entries[0].id).toBe(String(e3.seq)) + expect(page1.entries[1].id).toBe(String(e2.seq)) + expect(page1.nextBeforeSeq).toBe(e2.seq) + + const page2 = store.list({ limit: 5, beforeSeq: page1.nextBeforeSeq ?? undefined }) + expect(page2.entries.length).toBe(1) + expect(page2.entries[0].id).toBe(String(e1.seq)) + + const blob = store.getBlob(e1.dataHash!) + expect(blob).toBeTruthy() + expect((blob as any).data).toEqual(commonData) + }) + + test('redacts sensitive keys and truncates oversized payloads', () => { + const mem = new Database(':memory:') + ensureUiEventLogSchema(mem) + const store = createUiEventLogStore(mem) + + const e1 = store.append({ + type: 'info', + message: 'sensitive', + data: { + Authorization: 'Bearer SHOULD_NOT_PERSIST', + password: 'pw', + nested: { apiKey: 'key', ok: true } + }, + timestamp: new Date().toISOString(), + id: 'tmp' + }) + + const blob1 = store.getBlob(e1.dataHash!) + expect(blob1).toBeTruthy() + const data1 = (blob1 as any).data as any + expect(data1.Authorization).toBe('[REDACTED]') + expect(data1.password).toBe('[REDACTED]') + expect(data1.nested.apiKey).toBe('[REDACTED]') + expect(data1.nested.ok).toBe(true) + + const hugeObj: Record = {} + for (let i = 0; i < 120; i++) { + hugeObj[`k${i}`] = 'x'.repeat(4096) + } + const e2 = store.append({ + type: 'info', + message: 'huge', + data: hugeObj, + timestamp: new Date().toISOString(), + id: 'tmp2' + }) + const blob2 = store.getBlob(e2.dataHash!) + expect(blob2).toBeTruthy() + const data2 = (blob2 as any).data as any + expect(data2._truncated).toBe(true) + expect(data2.originalBytes).toBeGreaterThan(200_000) + expect(typeof data2.originalSha256).toBe('string') + expect(typeof data2.preview).toBe('string') + }) +}) diff --git a/src/db/ui-event-log.ts b/src/db/ui-event-log.ts new file mode 100644 index 0000000..f4e8a27 --- /dev/null +++ b/src/db/ui-event-log.ts @@ -0,0 +1,396 @@ +import { createHash } from 'node:crypto' +import type { Database } from 'bun:sqlite' +import db from './database.js' + +export type UiEventLogStreamEntry = { + type: string + message: string + data?: unknown + timestamp: string + id: string +} + +export type UiEventLogListItem = { + seq: number + createdAt: string + createdAtMs: number + type: string + message: string + timestamp: string + id: string + dataHash: string | null + dataPreview: unknown | null + dataBytes: number | null +} + +export type UiEventLogListResult = { + entries: UiEventLogListItem[] + nextBeforeSeq: number | null +} + +export type UiEventLogExportRow = { + seq: number + createdAtMs: number + type: string + message: string + dataHash: string | null + dataBytes: number | null + data: unknown | null +} + +function safeJsonStringify(value: unknown): string | null { + if (value === undefined) return null + try { + return JSON.stringify(value) + } catch { + try { + return JSON.stringify({ _error: 'non_serializable', preview: String(value) }) + } catch { + return null + } + } +} + +function sha256Hex(text: string): string { + return createHash('sha256').update(text).digest('hex') +} + +const DATA_PREVIEW_MAX_BYTES = 2048 +const MAX_PERSISTED_DATA_BYTES = 200_000 + +const REDACTED = '[REDACTED]' + +function looksSensitiveKey(key: string): boolean { + const k = key.trim().toLowerCase() + if (!k) return false + // Common secret-bearing keys. Keep this conservative: redact when we're confident. + if (k === 'authorization' || k === 'cookie' || k === 'set-cookie') return true + if (k === 'x-api-key' || k === 'api-key' || k === 'apikey' || k === 'api_key') return true + if (k === 'x-session-id' || k === 'session-id' || k === 'session_id' || k === 'sessionid') return true + if (k === 'password' || k === 'passwd' || k === 'pwd') return true + if (k === 'admin_secret' || k === 'adminsecret') return true + if (k === 'share_cred' || k === 'group_cred' || k === 'sharecred' || k === 'groupcred') return true + if (k === 'transport_sk' || k === 'transportkey' || k === 'transport_key') return true + if (k === 'session_secret' || k === 'sessionsecret') return true + if (k === 'derived_key' || k === 'derivedkey') return true + if (k.includes('secret') || k.includes('token')) return true + return false +} + +function truncateString(value: string, max = 4096): string { + if (value.length <= max) return value + const head = value.slice(0, Math.max(0, max - 64)) + const tail = value.slice(-48) + return `${head}…[truncated ${value.length - head.length - tail.length} chars]…${tail}` +} + +function sanitizeForPersistence(value: unknown): unknown { + const seen = new WeakSet() + const MAX_DEPTH = 10 + const MAX_KEYS = 5000 + const MAX_ARRAY = 5000 + + let keyCount = 0 + + const walk = (v: unknown, depth: number): unknown => { + if (v === null || v === undefined) return v + if (depth > MAX_DEPTH) return { _truncated: true, reason: 'max_depth' } + + const t = typeof v + if (t === 'string') return truncateString(v) + if (t === 'number' || t === 'boolean') return v + if (t === 'bigint') return v.toString() + if (t === 'function' || t === 'symbol') return String(v) + + if (v instanceof Error) { + return { + name: v.name, + message: v.message, + stack: typeof v.stack === 'string' ? truncateString(v.stack, 8192) : undefined, + } + } + + if (Array.isArray(v)) { + const out: unknown[] = [] + const n = Math.min(v.length, MAX_ARRAY) + for (let i = 0; i < n; i++) out.push(walk(v[i], depth + 1)) + if (v.length > n) out.push({ _truncated: true, reason: 'max_array', originalLength: v.length }) + return out + } + + if (t === 'object') { + const obj = v as Record + if (seen.has(obj)) return { _circular: true } + seen.add(obj) + + const out: Record = {} + for (const [k, rawVal] of Object.entries(obj)) { + keyCount++ + if (keyCount > MAX_KEYS) { + out._truncated = true + out._truncatedReason = 'max_keys' + break + } + if (looksSensitiveKey(k)) { + out[k] = REDACTED + continue + } + out[k] = walk(rawVal, depth + 1) + } + return out + } + + return String(v) + } + + return walk(value, 0) +} + +export function ensureUiEventLogSchema(dbConn: Database): void { + dbConn.exec(` + CREATE TABLE IF NOT EXISTS ui_event_log_blobs ( + hash TEXT PRIMARY KEY, + json TEXT NOT NULL, + byte_length INTEGER NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP + ); + `) + dbConn.exec(` + CREATE TABLE IF NOT EXISTS ui_event_log_entries ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at_ms INTEGER NOT NULL, + type TEXT NOT NULL, + message TEXT NOT NULL, + data_hash TEXT REFERENCES ui_event_log_blobs(hash), + data_preview TEXT, + data_bytes INTEGER, + source_id TEXT + ); + `) + dbConn.exec('CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_created_at_ms ON ui_event_log_entries(created_at_ms DESC)') + dbConn.exec('CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_type_seq ON ui_event_log_entries(type, seq DESC)') + dbConn.exec('CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_seq ON ui_event_log_entries(seq DESC)') +} + +export function createUiEventLogStore(dbConn: Database) { + ensureUiEventLogSchema(dbConn) + + const insertBlob = dbConn.prepare(` + INSERT OR IGNORE INTO ui_event_log_blobs (hash, json, byte_length) + VALUES (?, ?, ?) + `) + + const insertEntry = dbConn.prepare(` + INSERT INTO ui_event_log_entries ( + created_at_ms, type, message, data_hash, data_preview, data_bytes, source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + + const selectEntriesBase = (whereSql: string) => ` + SELECT + seq, + created_at, + created_at_ms, + type, + message, + data_hash, + data_preview, + data_bytes + FROM ui_event_log_entries + ${whereSql} + ORDER BY seq DESC + LIMIT ? + ` + + const selectBlob = dbConn.prepare('SELECT json, byte_length FROM ui_event_log_blobs WHERE hash = ?') + + return { + append(entry: UiEventLogStreamEntry): { seq: number; dataHash: string | null } { + const nowMs = Date.now() + + const type = String(entry.type || '').trim() || 'info' + const message = String(entry.message || '').trim() + + let dataHash: string | null = null + let dataPreview: string | null = null + let dataBytes: number | null = null + + const sanitizedData = sanitizeForPersistence(entry.data) + const json = safeJsonStringify(sanitizedData) + if (json !== null) { + const bytes = Buffer.byteLength(json, 'utf8') + if (bytes > MAX_PERSISTED_DATA_BYTES) { + const originalSha256 = sha256Hex(json) + const summary = { + _truncated: true, + reason: 'max_persist_bytes', + originalBytes: bytes, + originalSha256, + preview: json.slice(0, DATA_PREVIEW_MAX_BYTES), + } + const summaryJson = safeJsonStringify(summary) + if (summaryJson) { + const summaryHash = sha256Hex(summaryJson) + insertBlob.run(summaryHash, summaryJson, Buffer.byteLength(summaryJson, 'utf8')) + dataHash = summaryHash + dataPreview = summaryJson + // Track original size for operators and UI. + dataBytes = bytes + } + } else { + dataBytes = bytes + dataHash = sha256Hex(json) + insertBlob.run(dataHash, json, dataBytes) + if (dataBytes <= DATA_PREVIEW_MAX_BYTES) { + dataPreview = json + } else { + dataPreview = json.slice(0, DATA_PREVIEW_MAX_BYTES) + } + } + } + + const result = insertEntry.run( + nowMs, + type, + message, + dataHash, + dataPreview, + dataBytes, + entry.id ?? null + ) + + const seq = Number(result.lastInsertRowid) + return { seq, dataHash } + }, + + list(opts?: { limit?: number; beforeSeq?: number; types?: string[] }): UiEventLogListResult { + const limit = Math.min(Math.max(opts?.limit ?? 200, 1), 500) + const beforeSeq = opts?.beforeSeq + const types = opts?.types?.filter(t => typeof t === 'string' && t.trim().length > 0).map(t => t.trim()) ?? [] + + const clauses: string[] = [] + const params: any[] = [] + + if (typeof beforeSeq === 'number' && Number.isFinite(beforeSeq) && beforeSeq > 0) { + clauses.push('seq < ?') + params.push(beforeSeq) + } + + if (types.length > 0) { + const placeholders = types.map(() => '?').join(',') + clauses.push(`type IN (${placeholders})`) + params.push(...types) + } + + const whereSql = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' + const stmt = dbConn.prepare(selectEntriesBase(whereSql)) + const rows = stmt.all(...params, limit) as any[] + + const entries: UiEventLogListItem[] = rows.map(r => { + const createdAtMs = typeof r.created_at_ms === 'number' ? r.created_at_ms : Number(r.created_at_ms) + if (!Number.isFinite(createdAtMs)) { + console.warn(`[ui-event-log] Invalid created_at_ms for seq=${r.seq}, falling back to Date.now()`) + } + const createdAtIso = Number.isFinite(createdAtMs) ? new Date(createdAtMs).toISOString() : String(r.created_at ?? '') + let preview: unknown | null = null + if (typeof r.data_preview === 'string' && r.data_preview.trim().length > 0) { + try { preview = JSON.parse(r.data_preview) } catch { preview = r.data_preview } + } + return { + seq: Number(r.seq), + createdAt: createdAtIso, + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), + type: String(r.type ?? ''), + message: String(r.message ?? ''), + // Emit ISO timestamps so the browser can localize display consistently. + timestamp: createdAtIso, + id: String(r.seq), + dataHash: typeof r.data_hash === 'string' ? r.data_hash : null, + dataPreview: preview, + dataBytes: typeof r.data_bytes === 'number' ? r.data_bytes : (r.data_bytes == null ? null : Number(r.data_bytes)), + } + }) + + const nextBeforeSeq = entries.length === limit ? entries[entries.length - 1].seq : null + return { entries, nextBeforeSeq } + }, + + getBlob(hash: string): { data: unknown; byteLength: number } | null { + const key = (hash || '').trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(key)) return null + const row = selectBlob.get(key) as { json: string; byte_length: number } | undefined + if (!row || typeof row.json !== 'string') return null + try { + return { data: JSON.parse(row.json), byteLength: row.byte_length } + } catch { + return { data: row.json, byteLength: row.byte_length } + } + } + , + + exportChunk(opts?: { afterSeq?: number; untilSeq?: number; limit?: number; types?: string[] }): { rows: UiEventLogExportRow[]; nextAfterSeq: number | null } { + const limit = Math.min(Math.max(opts?.limit ?? 1000, 1), 5000) + const afterSeq = typeof opts?.afterSeq === 'number' && Number.isFinite(opts.afterSeq) && opts.afterSeq >= 0 ? opts.afterSeq : 0 + const untilSeq = typeof opts?.untilSeq === 'number' && Number.isFinite(opts.untilSeq) && opts.untilSeq > 0 ? opts.untilSeq : null + const types = opts?.types?.filter(t => typeof t === 'string' && t.trim().length > 0).map(t => t.trim()) ?? [] + + const clauses: string[] = ['e.seq > ?'] + const params: any[] = [afterSeq] + + if (untilSeq) { + clauses.push('e.seq <= ?') + params.push(untilSeq) + } + + if (types.length > 0) { + const placeholders = types.map(() => '?').join(',') + clauses.push(`e.type IN (${placeholders})`) + params.push(...types) + } + + const whereSql = `WHERE ${clauses.join(' AND ')}` + const stmt = dbConn.prepare(` + SELECT + e.seq as seq, + e.created_at_ms as created_at_ms, + e.type as type, + e.message as message, + e.data_hash as data_hash, + e.data_bytes as data_bytes, + b.json as data_json + FROM ui_event_log_entries e + LEFT JOIN ui_event_log_blobs b ON e.data_hash = b.hash + ${whereSql} + ORDER BY e.seq ASC + LIMIT ? + `) + const raw = stmt.all(...params, limit) as any[] + const rows: UiEventLogExportRow[] = raw.map(r => { + let parsed: unknown | null = null + if (typeof r.data_json === 'string' && r.data_json.length > 0) { + try { parsed = JSON.parse(r.data_json) } catch { parsed = r.data_json } + } + const createdAtMs = typeof r.created_at_ms === 'number' ? r.created_at_ms : Number(r.created_at_ms) + return { + seq: Number(r.seq), + createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : Date.now(), + type: String(r.type ?? ''), + message: String(r.message ?? ''), + dataHash: typeof r.data_hash === 'string' ? r.data_hash : null, + dataBytes: typeof r.data_bytes === 'number' ? r.data_bytes : (r.data_bytes == null ? null : Number(r.data_bytes)), + data: parsed + } + }) + const nextAfterSeq = rows.length === limit ? rows[rows.length - 1].seq : null + return { rows, nextAfterSeq } + } + } +} + +const defaultStore = createUiEventLogStore(db) + +export const appendUiEventLogEntry = defaultStore.append +export const listUiEventLogEntries = defaultStore.list +export const getUiEventLogBlob = defaultStore.getBlob +export const exportUiEventLogChunk = defaultStore.exportChunk diff --git a/src/node/manager.ts b/src/node/manager.ts index 0c9b0fc..2c04dff 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -90,11 +90,15 @@ const EVENT_MAPPINGS = { '/sign/rej': { type: 'sign', message: 'Signature request rejected' }, '/sign/ret': { type: 'sign', message: 'Signature shares aggregated' }, '/sign/err': { type: 'sign', message: 'Signature share aggregation failed' }, + '/sign/sender/req': { type: 'sign', message: 'Signature request sent' }, + '/sign/sender/res': { type: 'sign', message: 'Signature responses received' }, '/ecdh/req': { type: 'ecdh', message: 'ECDH request received' }, '/ecdh/res': { type: 'ecdh', message: 'ECDH response sent' }, '/ecdh/rej': { type: 'ecdh', message: 'ECDH request rejected' }, '/ecdh/ret': { type: 'ecdh', message: 'ECDH shares aggregated' }, '/ecdh/err': { type: 'ecdh', message: 'ECDH share aggregation failed' }, + '/ecdh/sender/req': { type: 'ecdh', message: 'ECDH request sent' }, + '/ecdh/sender/res': { type: 'ecdh', message: 'ECDH responses received' }, '/ping/req': { type: 'bifrost', message: 'Ping request' }, '/ping/res': { type: 'bifrost', message: 'Ping response' }, } as const; @@ -1314,7 +1318,13 @@ export function createBroadcastEvent(eventStreams: Set) { +export function createAddServerLog( + broadcastEvent: ReturnType, + opts?: { + // Optional DB-mode persistence hook. Return a stable monotonic seq ID when persisted. + persist?: (entry: { type: string; message: string; data?: any; timestamp: string; id: string }) => number | null + } +) { return function addServerLog(type: string, message: string, data?: any) { // Suppress noisy low‑value entries from the public event stream and console // - Signature aggregation events are very frequent and leak long IDs into UI @@ -1325,20 +1335,34 @@ export function createAddServerLog(broadcastEvent: ReturnType 0) { + logEntry.seq = seq + logEntry.id = String(seq) + } + } catch { + // Persistence is non-critical; fall back to ephemeral IDs. + } // Log to console for server logs if (data !== undefined && data !== null && data !== '') { - console.log(`[${timestamp}] ${type.toUpperCase()}: ${message}`, data); + const consoleTs = new Date().toLocaleTimeString(); + console.log(`[${consoleTs}] ${type.toUpperCase()}: ${message}`, data); } else { - console.log(`[${timestamp}] ${type.toUpperCase()}: ${message}`); + const consoleTs = new Date().toLocaleTimeString(); + console.log(`[${consoleTs}] ${type.toUpperCase()}: ${message}`); } // Broadcast to connected clients @@ -1473,9 +1497,24 @@ export function setupNodeEventListeners( const messageData = msg as { tag: unknown; [key: string]: unknown }; const tag = messageData.tag; - if (typeof tag === 'string') { - // Handle peer status updates for ping messages - if (tag === '/ping/req' || tag === '/ping/res') { + if (typeof tag === 'string') { + // Avoid double-logging for tags that also emit dedicated events with different signatures. + // These are logged by their dedicated handlers below. + if ( + tag === '/sign/sender/rej' || + tag === '/sign/sender/ret' || + tag === '/sign/sender/err' || + tag === '/sign/handler/rej' || + tag === '/ecdh/sender/rej' || + tag === '/ecdh/sender/ret' || + tag === '/ecdh/sender/err' || + tag === '/ecdh/handler/rej' + ) { + return + } + + // Handle peer status updates for ping messages + if (tag === '/ping/req' || tag === '/ping/res') { // Extract pubkey from env.pubkey (Nostr event structure) let fromPubkey: string | undefined = undefined; @@ -1646,28 +1685,8 @@ export function setupNodeEventListeners( node.on('/sign/sender/err', signSenderErrHandler); node.on('/sign/handler/rej', signHandlerRejHandler); - // Legacy direct event listeners for backward compatibility - only for events NOT handled by message handler - const legacyEvents = [ - // Only include events that aren't already handled by EVENT_MAPPINGS via message handler - { event: '/ecdh/sender/req', type: 'ecdh', message: 'ECDH request sent' }, - { event: '/ecdh/sender/res', type: 'ecdh', message: 'ECDH responses received' }, - { event: '/sign/sender/req', type: 'sign', message: 'Signature request sent' }, - { event: '/sign/sender/res', type: 'sign', message: 'Signature responses received' }, - // Note: Removed /ecdh/handler/req, /ecdh/handler/res, /sign/handler/req, /sign/handler/res - // because they're already handled by the message handler via EVENT_MAPPINGS - ]; - - legacyEvents.forEach(({ event, type, message }) => { - try { - const handler = (msg: unknown) => { - updateNodeActivity(addServerLog); - addServerLog(type, message, msg); - }; - (node as any).on(event, handler); - } catch (e) { - // Silently ignore if event doesn't exist - } - }); + // Legacy direct event listeners removed. + // Sender req/res tags are mapped in EVENT_MAPPINGS so they are logged via the message handler. } catch (e) { addServerLog('bifrost', 'Error setting up some legacy event listeners', e); } diff --git a/src/routes/event-log.ts b/src/routes/event-log.ts new file mode 100644 index 0000000..94125b5 --- /dev/null +++ b/src/routes/event-log.ts @@ -0,0 +1,168 @@ +import { HEADLESS } from '../const.js' +import { getSecureCorsHeaders, mergeVaryHeaders } from './utils.js' +import type { RouteContext, RequestAuth } from './types.js' + +function parseSeq(value: string | null): number | undefined { + if (!value) return undefined + const n = Number.parseInt(value, 10) + if (!Number.isFinite(n) || n < 0) return undefined + return n +} + +function parseLimit(value: string | null, fallback = 200): number { + if (!value) return fallback + const n = Number.parseInt(value, 10) + if (!Number.isFinite(n)) return fallback + return Math.min(Math.max(n, 1), 500) +} + +function parseBeforeSeq(value: string | null): number | undefined { + if (!value) return undefined + const n = Number.parseInt(value, 10) + if (!Number.isFinite(n) || n <= 0) return undefined + return n +} + +function parseTypes(value: string | null): string[] | undefined { + if (!value) return undefined + const parts = value.split(',').map(v => v.trim()).filter(Boolean) + return parts.length ? parts : undefined +} + +export async function handleEventLogRoute( + req: Request, + url: URL, + _context: RouteContext, + _auth: RequestAuth | null +): Promise { + if (!url.pathname.startsWith('/api/event-log')) return null + + // DB-mode only + if (HEADLESS) { + return Response.json({ error: 'UI event log unavailable in headless mode' }, { status: 404 }) + } + + const corsHeaders = getSecureCorsHeaders(req) + const mergedVary = mergeVaryHeaders(corsHeaders) + const headers = { + 'Content-Type': 'application/json', + ...corsHeaders, + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Key, X-Session-ID', + 'Vary': mergedVary, + } + + if (req.method === 'OPTIONS') return new Response(null, { status: 200, headers }) + + // GET /api/event-log?limit=200&beforeSeq=123&types=sign,error + if (url.pathname === '/api/event-log' && req.method === 'GET') { + const limit = parseLimit(url.searchParams.get('limit'), 200) + const beforeSeq = parseBeforeSeq(url.searchParams.get('beforeSeq')) + const types = parseTypes(url.searchParams.get('types')) + + try { + const { listUiEventLogEntries } = await import('../db/ui-event-log.js') + const result = listUiEventLogEntries({ limit, beforeSeq, types }) + return Response.json(result, { headers }) + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error('[event-log] list error:', error) + } + return Response.json({ error: 'Failed to list event log entries' }, { status: 500, headers }) + } + } + + // GET /api/event-log/blob/ + if (url.pathname.startsWith('/api/event-log/blob/') && req.method === 'GET') { + const hash = url.pathname.split('/').pop() || '' + try { + const { getUiEventLogBlob } = await import('../db/ui-event-log.js') + const blob = getUiEventLogBlob(hash) + if (!blob) return Response.json({ error: 'Not found' }, { status: 404, headers }) + return Response.json({ hash, ...blob }, { headers }) + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error('[event-log] blob error:', error) + } + return Response.json({ error: 'Failed to fetch event log payload' }, { status: 500, headers }) + } + } + + // GET /api/event-log/export?sinceSeq=1&untilSeq=9999&types=sign,error + if (url.pathname === '/api/event-log/export' && req.method === 'GET') { + const sinceSeq = parseSeq(url.searchParams.get('sinceSeq')) ?? 1 + const untilSeq = parseSeq(url.searchParams.get('untilSeq')) + const types = parseTypes(url.searchParams.get('types')) + + const exportHeaders = { + ...corsHeaders, + 'Vary': mergedVary, + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-store', + // Hint browsers to download. + 'Content-Disposition': `attachment; filename="igloo-event-log-${new Date().toISOString().slice(0, 10)}.ndjson"` + } + + try { + const { exportUiEventLogChunk } = await import('../db/ui-event-log.js') + const encoder = new TextEncoder() + + let afterSeq = Math.max(0, sinceSeq - 1) + let done = false + + const stream = new ReadableStream({ + async pull(controller) { + try { + if (done) { + controller.close() + return + } + + const chunk = exportUiEventLogChunk({ + afterSeq, + untilSeq: untilSeq && untilSeq > 0 ? untilSeq : undefined, + limit: 1000, + types + }) + + if (!chunk.rows.length) { + done = true + controller.close() + return + } + + for (const row of chunk.rows) { + const line = JSON.stringify({ + seq: row.seq, + timestamp: new Date(row.createdAtMs).toISOString(), + type: row.type, + message: row.message, + dataHash: row.dataHash, + dataBytes: row.dataBytes, + data: row.data ?? undefined + }) + '\n' + controller.enqueue(encoder.encode(line)) + } + + afterSeq = chunk.rows[chunk.rows.length - 1].seq + if (chunk.nextAfterSeq === null) { + done = true + } + } catch (err) { + done = true + controller.error(err) + } + } + }) + + return new Response(stream, { status: 200, headers: exportHeaders }) + } catch (error) { + if (process.env.NODE_ENV !== 'production') { + console.error('[event-log] export error:', error) + } + return Response.json({ error: 'Failed to export event log' }, { status: 500, headers }) + } + } + + return Response.json({ error: 'Not Found' }, { status: 404, headers }) +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 7a8f4c4..8183c72 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -8,6 +8,7 @@ export { handleSignRoute } from './sign.js'; export { handleNip44Route } from './nip44.js'; export { handleNip04Route } from './nip04.js'; export { handleNip46Route } from './nip46.js'; +export { handleEventLogRoute } from './event-log.js'; export { handleUpdateRoute } from './update.js'; // Export types and utilities @@ -25,6 +26,7 @@ import { handleSignRoute } from './sign.js'; import { handleNip44Route } from './nip44.js'; import { handleNip04Route } from './nip04.js'; import { handleNip46Route } from './nip46.js'; +import { handleEventLogRoute } from './event-log.js'; import { handleUpdateRoute } from './update.js'; import { handleDocsRoute } from './docs.js'; import { handleOnboardingRoute } from './onboarding.js'; @@ -311,6 +313,7 @@ export async function handleRequest( const routeHandlers = [ handleStatusRoute, // Allow unauthenticated for health checks handleUpdateRoute, + handleEventLogRoute, handlePeersRoute, handleSignRoute, handleNip44Route, diff --git a/src/routes/nip46.ts b/src/routes/nip46.ts index 7e497c3..75f1d6a 100644 --- a/src/routes/nip46.ts +++ b/src/routes/nip46.ts @@ -293,8 +293,25 @@ export async function handleNip46Route( const statusFilter = parseStatusFilter(url.searchParams.get('status')) const limitParam = url.searchParams.get('limit') const limit = limitParam ? Math.min(Math.max(parseInt(limitParam, 10) || 100, 1), 500) : 100 - const requests = listNip46Requests(userId, { status: statusFilter ?? undefined, limit }) - return Response.json({ requests }, { headers }) + const beforeCreatedAt = url.searchParams.get('beforeCreatedAt')?.trim() || '' + const beforeId = url.searchParams.get('beforeId')?.trim() || '' + + // Require both or neither for cursor-based pagination + if ((beforeCreatedAt && !beforeId) || (!beforeCreatedAt && beforeId)) { + return Response.json( + { error: 'Both beforeCreatedAt and beforeId are required for pagination' }, + { status: 400, headers } + ) + } + + const before = (beforeCreatedAt && beforeId) + ? { createdAt: beforeCreatedAt, id: beforeId } + : undefined + const requests = listNip46Requests(userId, { status: statusFilter ?? undefined, limit, before }) + const nextCursor = requests.length === limit + ? { createdAt: requests[requests.length - 1]?.created_at, id: requests[requests.length - 1]?.id } + : null + return Response.json({ requests, nextCursor }, { headers }) } if (req.method === 'POST') { diff --git a/src/routes/update.ts b/src/routes/update.ts index 5c0a377..b241939 100644 --- a/src/routes/update.ts +++ b/src/routes/update.ts @@ -85,6 +85,7 @@ function parseVersion(raw: string, allowPrerelease: boolean): ParsedVersion | nu const [core, prerelease] = withoutPrefix.split('-', 2); const parts = core.split('.'); if (parts.length < 3) return null; + if (parts.some(p => p === '' || !/^\d+$/.test(p))) return null; const major = Number(parts[0]); const minor = Number(parts[1]); diff --git a/src/server.ts b/src/server.ts index fa0e44e..520401e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -215,7 +215,12 @@ const restartState = { blockedByCredentials: false }; // Create event management functions const broadcastEvent = createBroadcastEvent(eventStreams); -const addServerLog = createAddServerLog(broadcastEvent); +let persistUiEventLogEntry: ((entry: { type: string; message: string; data?: any; timestamp: string; id: string }) => number | null) | null = null +const addServerLog = createAddServerLog(broadcastEvent, { + persist: (entry) => { + try { return persistUiEventLogEntry?.(entry) ?? null } catch { return null } + } +}); // NIP-46 service only needed in database mode (perf optimization 3.3) if (!CONST.HEADLESS) { initNip46Service({ @@ -360,6 +365,21 @@ async function initializeDatabase(): Promise { console.error('⚠️ Failed to initialize NIP-46 database:', e?.message || e); } + // Enable UI event log persistence in DB mode (non-critical; failures are tolerated). + try { + const uiLog = await import('./db/ui-event-log.js') + persistUiEventLogEntry = (entry) => { + try { + const result = uiLog.appendUiEventLogEntry(entry as any) + return result?.seq ?? null + } catch { + return null + } + } + } catch (e: any) { + console.error('⚠️ Failed to enable UI event log persistence:', e?.message || e) + } + // Initialize persistent rate limiter with database connection try { const dbDefault = await import('./db/database.js'); diff --git a/tests/routes/event-log.spec.ts b/tests/routes/event-log.spec.ts new file mode 100644 index 0000000..210073b --- /dev/null +++ b/tests/routes/event-log.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'bun:test'; +import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; + +describe('Event log routes', () => { + test('route unavailable in headless mode', () => { + const script = ` + const root = ${JSON.stringify(PROJECT_ROOT)}; + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'true'; + + const { handleEventLogRoute } = await import(root + 'src/routes/event-log.ts'); + const context = { + node: null, + addServerLog: () => {}, + broadcastEvent: () => {}, + peerStatuses: new Map(), + eventStreams: new Set(), + restartState: { blockedByCredentials: false }, + }; + + const req = new Request('http://localhost/api/event-log?limit=1'); + const res = await handleEventLogRoute(req, new URL(req.url), context, { authenticated: true, userId: 1 }); + const body = await res.json(); + console.log('@@RESULT@@' + JSON.stringify({ status: res.status, body })); + process.exit(0); + `; + + const result = runRouteScript(script); + expect(result.status).toBe(404); + expect(result.body?.error).toContain('headless'); + }); +}); + From aaf22083a3e351c2c815a76488f92b7990d2875e Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 16:59:38 -0600 Subject: [PATCH 12/69] type fix --- src/db/ui-event-log.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/db/ui-event-log.ts b/src/db/ui-event-log.ts index f4e8a27..f4dcadd 100644 --- a/src/db/ui-event-log.ts +++ b/src/db/ui-event-log.ts @@ -97,7 +97,7 @@ function sanitizeForPersistence(value: unknown): unknown { if (depth > MAX_DEPTH) return { _truncated: true, reason: 'max_depth' } const t = typeof v - if (t === 'string') return truncateString(v) + if (t === 'string') return truncateString(v as string) if (t === 'number' || t === 'boolean') return v if (t === 'bigint') return v.toString() if (t === 'function' || t === 'symbol') return String(v) From e616e05ac5928c7dad9cb6ff8afa591734426879 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 17:07:56 -0600 Subject: [PATCH 13/69] harden log-entry logic --- frontend/components/ui/log-entry.tsx | 42 ++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/frontend/components/ui/log-entry.tsx b/frontend/components/ui/log-entry.tsx index 71755a3..71c89d9 100644 --- a/frontend/components/ui/log-entry.tsx +++ b/frontend/components/ui/log-entry.tsx @@ -76,15 +76,18 @@ This is likely a complex object from the Bifrost node containing circular refere export const LogEntry = memo(({ log }: LogEntryProps) => { const [isMessageExpanded, setIsMessageExpanded] = React.useState(false); - const [resolvedData, setResolvedData] = React.useState(log.data ?? log.dataPreview); + const [resolvedData, setResolvedData] = React.useState(log.data ?? log.dataPreview ?? null); const [isLoadingData, setIsLoadingData] = React.useState(false); - const [hasFetchedFull, setHasFetchedFull] = React.useState(false); + const [hasFetchedFull, setHasFetchedFull] = React.useState(!!log.data); + const [fetchError, setFetchError] = React.useState(null); React.useEffect(() => { - if (!hasFetchedFull) { - setResolvedData(log.data ?? log.dataPreview); - } - }, [log.data, log.dataPreview, hasFetchedFull]); + // New log: reset derived state. Preview does not count as "full payload fetched". + setResolvedData(log.data ?? log.dataPreview ?? null); + setHasFetchedFull(!!log.data); + setFetchError(null); + setIsLoadingData(false); + }, [log.id, log.data, log.dataPreview, log.dataHash]); const hasData = !!(resolvedData && (typeof resolvedData !== 'object' || Object.keys(resolvedData).length > 0)) || !!log.dataHash; @@ -96,11 +99,13 @@ export const LogEntry = memo(({ log }: LogEntryProps) => { React.useEffect(() => { if (!isMessageExpanded) return; - if (resolvedData !== undefined && resolvedData !== null) return; + if (hasFetchedFull) return; + if (fetchError) return; // Don't auto-retry while expanded; user can collapse + re-expand. const hash = typeof log.dataHash === 'string' ? log.dataHash : null; if (!hash || !/^[a-f0-9]{64}$/.test(hash)) return; let cancelled = false; + setFetchError(null); setIsLoadingData(true); fetch(`/api/event-log/blob/${hash}`) .then(res => res.ok ? res.json() : Promise.reject(new Error('Failed to fetch'))) @@ -113,8 +118,8 @@ export const LogEntry = memo(({ log }: LogEntryProps) => { }) .catch(() => { if (cancelled) return; - // Keep UI usable even if blob fetch fails. - setResolvedData({ _error: 'failed_to_load_payload', hash }); + // Keep UI usable even if blob fetch fails; preserve preview (if any) and surface a retry hint. + setFetchError('failed_to_load_payload'); }) .finally(() => { if (cancelled) return; @@ -122,7 +127,16 @@ export const LogEntry = memo(({ log }: LogEntryProps) => { }); return () => { cancelled = true; }; - }, [isMessageExpanded, resolvedData, log.dataHash]); + }, [isMessageExpanded, hasFetchedFull, fetchError, log.dataHash]); + + React.useEffect(() => { + // Allow retries: if expansion is collapsed after a fetch error, reset error/loading state. + if (isMessageExpanded) return; + if (!fetchError) return; + setFetchError(null); + setIsLoadingData(false); + setResolvedData(log.data ?? log.dataPreview ?? null); + }, [isMessageExpanded, fetchError, log.data, log.dataPreview]); const signatureSummary = React.useMemo(() => { const data = resolvedData; @@ -140,8 +154,14 @@ export const LogEntry = memo(({ log }: LogEntryProps) => { const formattedData = React.useMemo(() => { if (!hasData) return null; if (isLoadingData) return 'Loading…'; + if (fetchError) { + const preview = resolvedData !== undefined && resolvedData !== null + ? `\n\nPreview:\n${formatLogData(resolvedData)}` + : ''; + return `Failed to load full payload. Collapse and re-expand to retry.${preview}`; + } return formatLogData(resolvedData); - }, [resolvedData, hasData, isLoadingData]); + }, [resolvedData, hasData, isLoadingData, fetchError]); return (
From b020927303d2279c7a8b7edb2899b0408a0907c0 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 17:28:42 -0600 Subject: [PATCH 14/69] type fix --- frontend/components/Signer.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index 9107fa9..5141c70 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -73,9 +73,8 @@ const sanitizeLogEntry = (entry: unknown): LogEntryData | null => { type: log.type, message: log.message, data: log.data, - // Pass through persistence hints when present. - dataHash: (log as any).dataHash, - dataPreview: (log as any).dataPreview + dataHash: log.dataHash, + dataPreview: log.dataPreview }; }; From 2aec5b13e1d473f50edcc6b738a8a10134e722dc Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 17:52:21 -0600 Subject: [PATCH 15/69] fix event log counter --- src/routes/event-log.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/event-log.ts b/src/routes/event-log.ts index 94125b5..86d2ae7 100644 --- a/src/routes/event-log.ts +++ b/src/routes/event-log.ts @@ -5,7 +5,7 @@ import type { RouteContext, RequestAuth } from './types.js' function parseSeq(value: string | null): number | undefined { if (!value) return undefined const n = Number.parseInt(value, 10) - if (!Number.isFinite(n) || n < 0) return undefined + if (!Number.isFinite(n) || n <= 0) return undefined return n } From 595d2c0413b2178730295652788387d55de5fb0e Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Mon, 9 Feb 2026 21:15:52 -0600 Subject: [PATCH 16/69] address code rabbit comments --- CHANGELOG.md | 10 +++++++ compose.yml | 3 +- docs/AUTH_MATRIX.md | 2 +- docs/SECURITY.md | 6 +--- env.example | 10 +------ frontend/components/Signer.tsx | 6 +++- llm/context/NIP46_IMPLEMENTATION.md | 4 +-- .../migrations/20260209_0001_ui_event_log.sql | 29 ------------------- src/db/ui-event-log.ts | 23 ++++++++++++--- src/routes/update.ts | 10 ++++--- 10 files changed, 46 insertions(+), 57 deletions(-) delete mode 100644 src/db/migrations/20260209_0001_ui_event_log.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 47699e2..f67b5b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ ### Added * Update check banner for non-managed installs, backed by `/api/update`. +* WebSocket events endpoint (`/api/events`) for real-time logs and status streaming. +* Server-persisted event log with UI controls: download and load older history. +* New documentation: CONFIG.md, AUTH_MATRIX.md, PEER_POLICIES.md. + +### Changed + +* Relay probe optimizations: `SKIP_RELAY_PROBE` and `DEFER_RELAY_PROBE` env vars for background verification. +* Docker Compose improvements: dev-only bind-mount via override, native arch (ARM/x86) support. +* Headless-mode session optimizations for faster startup. +* Mode-specific rate limits. ### Notes diff --git a/compose.yml b/compose.yml index d60a985..96b5bb0 100644 --- a/compose.yml +++ b/compose.yml @@ -19,7 +19,6 @@ services: - DB_PATH=/app/data/igloo.db container_name: igloo-server - platform: linux/x86_64 hostname: igloo-server restart: unless-stopped init: true @@ -39,13 +38,13 @@ services: - "8002:8002" volumes: - - ./src:/app/src:rw # Mount .env so `/api/env` writes persist back to the host file. # Guard: create it first (`cp env.example .env` or `touch .env`) to avoid Docker creating `./.env/` as a directory. # Reminder: changes written here won't affect the running container's env until restart (see env_file note above). - ./.env:/app/.env:rw # Persist database and session secrets between container recreations - ./data:/app/data:rw + # For local dev live-reload, use docker-compose.override.yml (adds ./src:/app/src:rw) networks: infranet: diff --git a/docs/AUTH_MATRIX.md b/docs/AUTH_MATRIX.md index 099bb77..d7a3846 100644 --- a/docs/AUTH_MATRIX.md +++ b/docs/AUTH_MATRIX.md @@ -12,7 +12,7 @@ Definitions: ## Endpoint Matrix | Endpoint(s) | Purpose | DB mode | Headless mode | Bypasses global auth gate when `AUTH_ENABLED=true` | Notes | -|---|---:|:---:|:---:|:---:|---| +|---|---|:---:|:---:|:---:|---| | `/api/status` | Health/status | Yes | Yes | Yes | Public health checks; if auth headers are present the server will attempt auth and include extra details. | | `/api/update` | Update check | Yes | Yes | Yes | Update checks are disabled for managed deployments (e.g., `HEADLESS=true` or `SKIP_ADMIN_SECRET_VALIDATION=true`) and when `UPDATE_CHECK_DISABLED=true`. | | `/api/auth/status` | Auth capabilities | Yes | Yes | Yes | Returns configured auth methods and mode signals. | diff --git a/docs/SECURITY.md b/docs/SECURITY.md index f515e26..43a2abe 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -668,8 +668,4 @@ for i in {1..10}; do curl http://localhost:8002/api/status; done - [ ] Test HTTPS works in production - [ ] Test CORS headers are appropriate -**Remember**: Security is a process, not a destination. Regularly review and update your security configuration. -4. **Directional Peer Policies** (optional): - - Defaults allow both inbound and outbound traffic. - - To deny a direction, supply `allowSend:false` and/or `allowReceive:false` in `PEER_POLICIES`. - - The server persists and mirrors saved overrides into `data/peer-policies.json` so they persist between restarts (see `docs/PEER_POLICIES.md`). +**Remember**: Security is a process, not a destination. Regularly review and update your security configuration. diff --git a/env.example b/env.example index 04c236f..0e75ec8 100644 --- a/env.example +++ b/env.example @@ -108,15 +108,7 @@ RATE_LIMIT_WINDOW=900 # Maximum requests per window per IP address RATE_LIMIT_MAX=600 -# WebSocket upgrade abuse protection (both /api/events and / WebSocket upgrades) -# Window defaults to RATE_LIMIT_WINDOW when unset; max defaults to 30 -# RATE_LIMIT_WS_UPGRADE_WINDOW=900 -# RATE_LIMIT_WS_UPGRADE_MAX=30 -# -# WebSocket per-IP connection cap and message rate limiting -# WS_MAX_CONNECTIONS_PER_IP=5 -# WS_MSG_RATE=20 -# WS_MSG_BURST=40 +# WebSocket abuse settings: see WEBSOCKET ABUSE PROTECTION (ADVANCED) below. # NIP-46 session creation limits (per user) # Defaults: 1 hour window across modes diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index 5141c70..a76d54f 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -580,7 +580,11 @@ const Signer = forwardRef(({ initialData, authHeaders return merged; }); const seqs = chronological.map(e => parseSeq(e.id)).filter((n): n is number => n !== null); - const minSeq = seqs.length ? Math.min(...seqs) : oldestSeq; + if (seqs.length === 0) { + setHasMoreHistory(false); + return; + } + const minSeq = Math.min(...seqs); setOldestSeq(minSeq); setHasMoreHistory(typeof nextBeforeSeq === 'number' ? nextBeforeSeq > 0 : chronological.length === 200); } finally { diff --git a/llm/context/NIP46_IMPLEMENTATION.md b/llm/context/NIP46_IMPLEMENTATION.md index fc86a32..b41e86e 100644 --- a/llm/context/NIP46_IMPLEMENTATION.md +++ b/llm/context/NIP46_IMPLEMENTATION.md @@ -168,7 +168,7 @@ Base path: `/api/nip46/` | Method | Path | Description | |--------|------|-------------| | `GET` | `/requests` | List requests (`?status=pending,approved&limit=100`, max 500) | -| `POST` | `/requests` | Update request status (`action=approve|deny|fail|complete`); optional policy patch | +| `POST` | `/requests` | Update request status (`action=approve\|deny\|fail\|complete`); optional policy patch | | `DELETE` | `/requests` | Delete request (body includes `id`) | ### History @@ -258,7 +258,7 @@ The server prefers an explicit `invite.policy` (from the decoded connect string) The `nostrconnect://` URI can include a `perms` parameter: -``` +```text nostrconnect://pubkey?relay=wss://...&perms=sign_event:1,sign_event:4,nip44_encrypt ``` diff --git a/src/db/migrations/20260209_0001_ui_event_log.sql b/src/db/migrations/20260209_0001_ui_event_log.sql deleted file mode 100644 index a17cbac..0000000 --- a/src/db/migrations/20260209_0001_ui_event_log.sql +++ /dev/null @@ -1,29 +0,0 @@ --- UI event log persistence (DB mode) --- Stores every UI-visible server log entry for backscroll + auditability. --- Payloads are de-duplicated by sha256 hash to reduce space without losing entries. - -CREATE TABLE IF NOT EXISTS ui_event_log_blobs ( - hash TEXT PRIMARY KEY, - json TEXT NOT NULL, - byte_length INTEGER NOT NULL, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE IF NOT EXISTS ui_event_log_entries ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_at_ms INTEGER NOT NULL, - type TEXT NOT NULL, - message TEXT NOT NULL, - -- Optional payload reference (JSON stored in ui_event_log_blobs) - data_hash TEXT REFERENCES ui_event_log_blobs(hash), - data_preview TEXT, - data_bytes INTEGER, - -- Original event id emitted by the server (pre-persistence). Useful for debugging. - source_id TEXT -); - -CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_created_at_ms ON ui_event_log_entries(created_at_ms DESC); -CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_type_seq ON ui_event_log_entries(type, seq DESC); -CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_seq ON ui_event_log_entries(seq DESC); - diff --git a/src/db/ui-event-log.ts b/src/db/ui-event-log.ts index f4dcadd..2b4d8dc 100644 --- a/src/db/ui-event-log.ts +++ b/src/db/ui-event-log.ts @@ -173,6 +173,21 @@ export function ensureUiEventLogSchema(dbConn: Database): void { dbConn.exec('CREATE INDEX IF NOT EXISTS idx_ui_event_log_entries_seq ON ui_event_log_entries(seq DESC)') } +type EventLogEntryRow = { + seq: number + created_at: string + created_at_ms: number | string + type: string + message: string + data_hash: string | null + data_preview: string | null + data_bytes: number | null +} + +type EventLogExportRow = EventLogEntryRow & { + data_json: string | null +} + export function createUiEventLogStore(dbConn: Database) { ensureUiEventLogSchema(dbConn) @@ -270,7 +285,7 @@ export function createUiEventLogStore(dbConn: Database) { const types = opts?.types?.filter(t => typeof t === 'string' && t.trim().length > 0).map(t => t.trim()) ?? [] const clauses: string[] = [] - const params: any[] = [] + const params: (number | string)[] = [] if (typeof beforeSeq === 'number' && Number.isFinite(beforeSeq) && beforeSeq > 0) { clauses.push('seq < ?') @@ -285,7 +300,7 @@ export function createUiEventLogStore(dbConn: Database) { const whereSql = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '' const stmt = dbConn.prepare(selectEntriesBase(whereSql)) - const rows = stmt.all(...params, limit) as any[] + const rows = stmt.all(...params, limit) as EventLogEntryRow[] const entries: UiEventLogListItem[] = rows.map(r => { const createdAtMs = typeof r.created_at_ms === 'number' ? r.created_at_ms : Number(r.created_at_ms) @@ -336,7 +351,7 @@ export function createUiEventLogStore(dbConn: Database) { const types = opts?.types?.filter(t => typeof t === 'string' && t.trim().length > 0).map(t => t.trim()) ?? [] const clauses: string[] = ['e.seq > ?'] - const params: any[] = [afterSeq] + const params: (number | string)[] = [afterSeq] if (untilSeq) { clauses.push('e.seq <= ?') @@ -365,7 +380,7 @@ export function createUiEventLogStore(dbConn: Database) { ORDER BY e.seq ASC LIMIT ? `) - const raw = stmt.all(...params, limit) as any[] + const raw = stmt.all(...params, limit) as EventLogExportRow[] const rows: UiEventLogExportRow[] = raw.map(r => { let parsed: unknown | null = null if (typeof r.data_json === 'string' && r.data_json.length > 0) { diff --git a/src/routes/update.ts b/src/routes/update.ts index b241939..fe79555 100644 --- a/src/routes/update.ts +++ b/src/routes/update.ts @@ -37,9 +37,9 @@ interface UpdateResponse { error?: string; } -const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10); -const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10); // 6 hours -const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10); // 15 minutes +const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10) || 5000; +const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10) || 21_600_000; // 6 hours +const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10) || 900_000; // 15 minutes const GITHUB_OWNER = 'FROSTR-ORG'; const GITHUB_REPO = 'igloo-server'; @@ -82,7 +82,9 @@ function parseVersion(raw: string, allowPrerelease: boolean): ParsedVersion | nu const withoutPrefix = trimmed.startsWith('v') || trimmed.startsWith('V') ? trimmed.slice(1) : trimmed; - const [core, prerelease] = withoutPrefix.split('-', 2); + const dashIdx = withoutPrefix.indexOf('-'); + const core = dashIdx === -1 ? withoutPrefix : withoutPrefix.slice(0, dashIdx); + const prerelease = dashIdx === -1 ? undefined : withoutPrefix.slice(dashIdx + 1); const parts = core.split('.'); if (parts.length < 3) return null; if (parts.some(p => p === '' || !/^\d+$/.test(p))) return null; From 954d76ce5741634897051c5dee75e8bb44f9db8f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 10 Feb 2026 07:33:03 -0600 Subject: [PATCH 17/69] extracted loadInitialHistory as a reusable callback, and handleClearLogs now calls it after resetting state so oldestSeq gets repopulated and the 'load older' button works again --- frontend/components/Signer.tsx | 71 +++++++++++++++++----------------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index a76d54f..5ba0d6e 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -518,44 +518,43 @@ const Signer = forwardRef(({ initialData, authHeaders }; }, []); - // Load initial persisted history (DB mode). The realtime WebSocket continues to append new events. - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - const res = await fetch('/api/event-log?limit=200', { headers: authHeaders }); - if (!res.ok) { - if (res.status === 401) { - try { window.dispatchEvent(new CustomEvent('authExpired')); } catch {} - } - return; + // Loads the first page of persisted history from the server. + const loadInitialHistory = useCallback(async () => { + try { + const res = await fetch('/api/event-log?limit=200', { headers: authHeaders }); + if (!res.ok) { + if (res.status === 401) { + try { window.dispatchEvent(new CustomEvent('authExpired')); } catch {} } - const payload = await res.json(); - const entries: unknown = (payload as any)?.entries; - const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq; - if (!Array.isArray(entries)) return; - const sanitized = entries.map(sanitizeLogEntry).filter((e): e is LogEntryData => e !== null); - const chronological = [...sanitized].reverse(); - if (cancelled) return; - setLogs(prev => { - if (prev.length === 0) return chronological; - const existing = new Set(prev.map(e => e.id)); - const merged = [...chronological.filter(e => !existing.has(e.id)), ...prev]; - return merged; - }); - const seqs = chronological.map(e => parseSeq(e.id)).filter((n): n is number => n !== null); - const minSeq = seqs.length ? Math.min(...seqs) : null; - setOldestSeq(minSeq); - setHasMoreHistory(typeof nextBeforeSeq === 'number' ? nextBeforeSeq > 0 : chronological.length === 200); - } catch { - // Ignore history load errors; realtime stream still works. + return; } - }; + const payload = await res.json(); + const entries: unknown = (payload as any)?.entries; + const nextBeforeSeq: unknown = (payload as any)?.nextBeforeSeq; + if (!Array.isArray(entries)) return; + const sanitized = entries.map(sanitizeLogEntry).filter((e): e is LogEntryData => e !== null); + const chronological = [...sanitized].reverse(); + setLogs(prev => { + if (prev.length === 0) return chronological; + const existing = new Set(prev.map(e => e.id)); + const merged = [...chronological.filter(e => !existing.has(e.id)), ...prev]; + return merged; + }); + const seqs = chronological.map(e => parseSeq(e.id)).filter((n): n is number => n !== null); + const minSeq = seqs.length ? Math.min(...seqs) : null; + setOldestSeq(minSeq); + setHasMoreHistory(typeof nextBeforeSeq === 'number' ? nextBeforeSeq > 0 : chronological.length === 200); + } catch { + // Ignore history load errors; realtime stream still works. + } + }, [authHeaders]); + + // Load initial persisted history (DB mode). The realtime WebSocket continues to append new events. + useEffect(() => { if (!isHeadlessMode) { - void load(); + void loadInitialHistory(); } - return () => { cancelled = true; }; - }, [authHeaders, isHeadlessMode]); + }, [loadInitialHistory, isHeadlessMode]); const handleLoadOlder = useCallback(async () => { if (!oldestSeq || loadingOlder) return; @@ -1009,7 +1008,9 @@ const Signer = forwardRef(({ initialData, authHeaders setLogs([]); setOldestSeq(null); setHasMoreHistory(false); - }, []); + // Re-load the first page so oldestSeq is repopulated and "load older" works again. + void loadInitialHistory(); + }, [loadInitialHistory]); // Show loading state while fetching environment variables if (isLoading) { From 4f3881727b560ae3d21f8380740bfb37bf12ffe7 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 10 Feb 2026 08:50:04 -0600 Subject: [PATCH 18/69] remove any types from event log persist chain, fix stale version in docs --- llm/implementation/credential-storage-implementation.md | 8 ++++---- llm/implementation/umbrel-implementation.md | 2 +- src/node/manager.ts | 6 +++--- src/server.ts | 5 +++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/llm/implementation/credential-storage-implementation.md b/llm/implementation/credential-storage-implementation.md index 3f5827d..8f2232b 100644 --- a/llm/implementation/credential-storage-implementation.md +++ b/llm/implementation/credential-storage-implementation.md @@ -14,11 +14,11 @@ This document captures how Igloo Server stores, encrypts, and retrieves user cre ## Data Model - Credentials are stored in SQLite in the `users` table. - Encrypted fields: -- `group_cred_encrypted` and `share_cred_encrypted` store ciphertext (AES-256-GCM, base64). + - `group_cred_encrypted` and `share_cred_encrypted` store ciphertext (AES-256-GCM, base64). - Plaintext fields: -- `relays` and `group_name` are stored as plain JSON/string (not encrypted). -- `salt` is stored in plaintext and used only for PBKDF2 key derivation. -- `password_hash` stores an Argon2id hash with embedded salt for authentication. + - `relays` and `group_name` are stored as plain JSON/string (not encrypted). + - `salt` is stored in plaintext and used only for PBKDF2 key derivation. + - `password_hash` stores an Argon2id hash with embedded salt for authentication. ## Crypto Configuration Defined in `src/config/crypto.ts`: diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 903f794..8c3b8c9 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -20,7 +20,7 @@ Key files and current state: - Store id: `igloo` - Store name: `Igloo Server Store` - `igloo-server/umbrel-app.yml` - - `version: 1.1.0` + - `version: 1.1.1` - `port: 8002`, `tor: true` - Assets are remote URLs (icon + gallery screenshots). - Description calls out database mode defaults and admin secret auto-provisioning. diff --git a/src/node/manager.ts b/src/node/manager.ts index 2c04dff..dbd55b8 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -1322,10 +1322,10 @@ export function createAddServerLog( broadcastEvent: ReturnType, opts?: { // Optional DB-mode persistence hook. Return a stable monotonic seq ID when persisted. - persist?: (entry: { type: string; message: string; data?: any; timestamp: string; id: string }) => number | null + persist?: (entry: { type: string; message: string; data?: unknown; timestamp: string; id: string }) => number | null } ) { - return function addServerLog(type: string, message: string, data?: any) { + return function addServerLog(type: string, message: string, data?: unknown) { // Suppress noisy low‑value entries from the public event stream and console // - Signature aggregation events are very frequent and leak long IDs into UI // Keep them out of the event log while preserving other SIGN entries. @@ -1337,7 +1337,7 @@ export function createAddServerLog( } // Use ISO timestamp so browsers can localize display consistently. const timestamp = new Date().toISOString(); - const logEntry: { type: string; message: string; data?: any; timestamp: string; id: string; seq?: number } = { + const logEntry: { type: string; message: string; data?: unknown; timestamp: string; id: string; seq?: number } = { type, message, data, diff --git a/src/server.ts b/src/server.ts index 520401e..90086b6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,6 +13,7 @@ import type { NodeCredentialSnapshot } from './routes/index.js'; import { assertNoSessionSecretExposure, isWebSocketOriginAllowed, getTrustedClientIp } from './routes/utils.js'; +import type { UiEventLogStreamEntry } from './db/ui-event-log.js'; import { createBroadcastEvent, createAddServerLog, @@ -215,7 +216,7 @@ const restartState = { blockedByCredentials: false }; // Create event management functions const broadcastEvent = createBroadcastEvent(eventStreams); -let persistUiEventLogEntry: ((entry: { type: string; message: string; data?: any; timestamp: string; id: string }) => number | null) | null = null +let persistUiEventLogEntry: ((entry: UiEventLogStreamEntry) => number | null) | null = null const addServerLog = createAddServerLog(broadcastEvent, { persist: (entry) => { try { return persistUiEventLogEntry?.(entry) ?? null } catch { return null } @@ -370,7 +371,7 @@ async function initializeDatabase(): Promise { const uiLog = await import('./db/ui-event-log.js') persistUiEventLogEntry = (entry) => { try { - const result = uiLog.appendUiEventLogEntry(entry as any) + const result = uiLog.appendUiEventLogEntry(entry) return result?.seq ?? null } catch { return null From db0511f2d9e236ec28b92d044dd23b1799c651dc Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 10 Feb 2026 16:44:26 -0600 Subject: [PATCH 19/69] log storage optimizations and options --- README.md | 1 + docs/AUTH_MATRIX.md | 1 + docs/CONFIG.md | 15 ++ docs/EVENT_LOG.md | 65 ++++++ docs/README.md | 1 + docs/openapi/README.md | 1 + docs/openapi/openapi.json | 285 +++++++++++++++++++++++++ docs/openapi/openapi.yaml | 219 +++++++++++++++++++ env.example | 11 + frontend/components/Signer.tsx | 3 +- frontend/components/ui/event-log.tsx | 16 +- frontend/components/ui/peer-list.tsx | 12 +- src/db/ui-event-log.test.ts | 43 ++++ src/db/ui-event-log.ts | 36 ++++ src/node/manager.ts | 16 +- src/server.ts | 22 ++ tests/routes/event-log.db-mode.spec.ts | 165 ++++++++++++++ 17 files changed, 905 insertions(+), 7 deletions(-) create mode 100644 docs/EVENT_LOG.md create mode 100644 tests/routes/event-log.db-mode.spec.ts diff --git a/README.md b/README.md index d600cc7..c723562 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Looking to deploy quickly? Start with the one-click options in `docs/DEPLOY.md` ## Features - Always‑on signer built on igloo‑core with multi‑relay support - Web UI (React + Tailwind) for setup, monitoring, recovery +- Persisted UI event log (DB mode) with pagination and NDJSON export download - REST + WebSocket APIs with API‑key, Basic, or session auth - Health monitor + auto‑restart on repeated failures - Works as a single node or part of a k‑of‑n signer group diff --git a/docs/AUTH_MATRIX.md b/docs/AUTH_MATRIX.md index d7a3846..eb6b621 100644 --- a/docs/AUTH_MATRIX.md +++ b/docs/AUTH_MATRIX.md @@ -21,6 +21,7 @@ Definitions: | `/api/onboarding/*` | First-run onboarding | Yes | No | Yes | Only mounted in DB mode. Intended to be unauthenticated; protected by rate limiting and `ADMIN_SECRET` (unless `SKIP_ADMIN_SECRET_VALIDATION=true`). | | `/api/docs/*` | Swagger UI + raw spec | Yes | Yes | Special | Not behind the global gate, but in `NODE_ENV=production` with `AUTH_ENABLED=true` the docs require auth. | | `/api/events` (WebSocket) | Server event stream | Yes | Yes | No | WebSocket upgrade is authorized like normal API requests when `AUTH_ENABLED=true`. Origin checks apply for browsers (see `docs/CONFIG.md`). | +| `/api/event-log*` | Persisted UI event log (history, blobs, export) | Yes | No | No | DB mode only. `GET /api/event-log` is paginated; `GET /api/event-log/export` downloads NDJSON; `GET /api/event-log/blob/` fetches full payload by hash. | | `/api/env`, `/api/env/delete` | Read/write env-backed config | Yes | Yes | No | DB mode: reads require a valid session when `AUTH_ENABLED=true`; writes require admin (`ADMIN_SECRET` or admin role). Headless: reads and writes require API key or Basic Auth even if `AUTH_ENABLED=false`. | | `/api/env/shares` | Headless share metadata/upload | No | Yes | No | Intentionally headless-only; returns 404 in DB mode. | | `/api/env/admin-secret` | Reveal `ADMIN_SECRET` (guarded) | Yes | No | No | DB mode only; requires an admin session and explicit confirmation in body. | diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 05b19eb..f30b6c6 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -117,6 +117,21 @@ Performance toggles (advanced): - `SKIP_STARTUP_ECHO` (skips headless startup echo broadcasts) - `MAX_PEER_STATUS_ENTRIES` (bounds peer status memory) +## UI Event Log (DB Mode Only) + +In database mode (`HEADLESS=false`), the UI "Event Log" is persisted server-side in SQLite (within the same `igloo.db` under your `DB_PATH`). + +For details on retention, de-duplication, and disk usage, see `docs/EVENT_LOG.md`. + +API surfaces (DB mode only): +- `GET /api/event-log` paginates recent history (use `beforeSeq` to page older entries, and `types` to filter). +- `GET /api/event-log/blob/` fetches the full JSON payload for a log entry by content hash (the UI loads this lazily on expand). +- `GET /api/event-log/export` downloads the full log stream as NDJSON (one entry per line). + +Growth control: +- `UI_EVENT_LOG_INCLUDE_PINGS=false` (default) suppresses `/ping/*` request/response entries from the persisted UI event log to avoid runaway growth on long-running deployments. Enable only when debugging ping behavior. +- `UI_EVENT_LOG_RETENTION_DAYS`: optional; when set to a positive integer, Igloo will periodically prune persisted UI event log entries older than N days (and delete unreferenced payload blobs). + ## DB_PATH Semantics `DB_PATH` can be either: diff --git a/docs/EVENT_LOG.md b/docs/EVENT_LOG.md new file mode 100644 index 0000000..c3db9aa --- /dev/null +++ b/docs/EVENT_LOG.md @@ -0,0 +1,65 @@ +# UI Event Log (DB Mode) + +This doc describes how the UI "Event Log" works in database mode (`HEADLESS=false`), with emphasis on disk usage and long-running deployments. + +In headless mode (`HEADLESS=true`), `/api/event-log*` is unavailable (404) and you typically rely on process stdout logging instead. + +## Persistence Model + +In DB mode, UI event log entries are persisted to the same SQLite database as the rest of the DB-mode state (typically `./data/igloo.db`, or under `DB_PATH`). + +API surfaces: +- `GET /api/event-log` lists recent entries (reverse chronological; cursor pagination via `beforeSeq`). +- `GET /api/event-log/blob/` fetches the full JSON payload for an entry by content hash. +- `GET /api/event-log/export` downloads NDJSON (one entry per line) for easy support/debugging. + +## Storage Optimizations + +### 1) High-Volume Event Suppression (Pings) + +`/ping/*` traffic can be extremely frequent on always-on deployments and can dominate the event log over time. + +By default, ping request/response events are suppressed from the persisted UI event log: +- Env: `UI_EVENT_LOG_INCLUDE_PINGS=false` (default) + +Ping traffic is still used internally for peer status/latency; it just does not get persisted into the UI event log unless explicitly enabled. + +### 2) Payload De-duplication (Content-Addressed Blobs) + +Persisted payloads are stored by **SHA-256 hash** in a blob table. Event log entries reference payloads by hash: + +- If the same JSON payload repeats across many entries, the database stores it once. +- Entries remain fully auditable: each entry is still recorded, but large repeated payloads do not multiply disk usage. + +### 3) Lazy Loading Full Payloads + +The log list endpoint returns a small `dataPreview` and `dataHash` for each entry. + +The UI only fetches the full `data` from `/api/event-log/blob/` when a row is expanded. This reduces bandwidth and keeps initial UI loads snappy even with a large history. + +### 4) Redaction and Size Bounding + +Before persistence: +- Known secret-bearing keys are redacted (example: `Authorization`, `Cookie`, `*_secret`, `*_token`, `transport_sk`, etc). +- Objects are sanitized to avoid cycles and other non-JSON values. +- Oversized payloads are bounded: + - If the serialized payload exceeds a hard cap, a summary blob is stored instead with `_truncated: true`, and the entry records the original byte size. + +This prevents accidental persistence of sensitive material and prevents single requests/responses from exploding disk usage. + +### 5) Optional Retention (Auto-Prune) + +You can opt into pruning old entries automatically: +- Env: `UI_EVENT_LOG_RETENTION_DAYS=` + +When set to a positive integer, Igloo periodically deletes event log entries older than N days, then deletes any now-unreferenced payload blobs. + +Important: this is a tradeoff. Retention reduces disk usage but also reduces "full history" auditability. + +## Operational Guidance + +Recommended defaults for long-running deployments: +- Keep ping suppression enabled (`UI_EVENT_LOG_INCLUDE_PINGS=false`) unless actively debugging pings. +- If you need bounded disk usage, set a retention window (example `UI_EVENT_LOG_RETENTION_DAYS=30` or `90`). +- Use `/api/event-log/export` for support bundles instead of screenshots or manual copying. + diff --git a/docs/README.md b/docs/README.md index 5b8fe71..60eb0ce 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,5 +7,6 @@ - **Peer Policies** — `docs/PEER_POLICIES.md`: schema, precedence, and persistence for directional peer policies. - **Release** — `docs/RELEASE.md`: how we cut tags, build images (incl. Umbrel), and emergency fixes. - **API** — `docs/openapi/README.md`: OpenAPI 3.1 source, `/api/docs` usage, lint/bundle commands. +- **UI Event Log** — `docs/EVENT_LOG.md`: DB-mode UI event log persistence, export, and storage optimizations. If you just want to run Igloo, open `docs/DEPLOY.md` and follow the Umbrel quick path. diff --git a/docs/openapi/README.md b/docs/openapi/README.md index 98a01a9..d27d77e 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -74,6 +74,7 @@ The OpenAPI specification includes (major surfaces): - ✅ Key recovery (`/api/recover/*`) - ✅ Share management (`/api/env/shares`) - ✅ Real-time events (WebSocket stream at `/api/events`) +- ✅ Persisted UI event log (DB mode) (`/api/event-log*`) - ✅ Signing and encryption - `/api/sign` (threshold Schnorr signing) - `/api/nip44/{encrypt|decrypt}` (ECDH + NIP‑44) diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index d2a020b..2b5d74d 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -222,6 +222,177 @@ } } }, + "/api/event-log": { + "get": { + "operationId": "listUiEventLog", + "summary": "List persisted UI event log entries", + "description": "Lists entries from the persisted UI event log (database mode only).\n\nNotes:\n- Results are returned in reverse chronological order (`seq` descending).\n- Use `beforeSeq` to paginate older entries.\n- Use `types` (comma-separated) to filter by event type.\n", + "tags": [ + "Event Log" + ], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500, + "default": 200 + }, + "description": "Maximum number of entries to return (clamped to 1..500)." + }, + { + "name": "beforeSeq", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "description": "Return entries with `seq < beforeSeq` (pagination cursor)." + }, + { + "name": "types", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated list of event types to include (e.g., `sign,error`)." + } + ], + "responses": { + "200": { + "description": "Event log entries", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiEventLogListResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/event-log/blob/{hash}": { + "get": { + "operationId": "getUiEventLogBlob", + "summary": "Fetch a persisted UI event log payload by hash", + "description": "Fetches the full JSON payload for a persisted UI event log entry by content hash (database mode only).\n\nThe UI typically loads this lazily when a log entry is expanded.\n", + "tags": [ + "Event Log" + ], + "parameters": [ + { + "name": "hash", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "description": "SHA-256 hex hash of the persisted payload." + } + ], + "responses": { + "200": { + "description": "Event log payload", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UiEventLogBlobResponse" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, + "/api/event-log/export": { + "get": { + "operationId": "exportUiEventLog", + "summary": "Export persisted UI event log as NDJSON", + "description": "Exports the persisted UI event log as newline-delimited JSON (NDJSON), one entry per line (database mode only).\n\nQuery options:\n- `sinceSeq` (default 1): include entries with `seq >= sinceSeq`.\n- `untilSeq`: include entries with `seq <= untilSeq`.\n- `types`: comma-separated list of event types to include.\n", + "tags": [ + "Event Log" + ], + "parameters": [ + { + "name": "sinceSeq", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "description": "Lowest `seq` to include (inclusive)." + }, + { + "name": "untilSeq", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "description": "Highest `seq` to include (inclusive)." + }, + { + "name": "types", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Comma-separated list of event types to include (e.g., `sign,error`)." + } + ], + "responses": { + "200": { + "description": "NDJSON export stream. Each line is a JSON object:\n`{ seq, timestamp, type, message, dataHash, dataBytes, data? }`\n", + "content": { + "application/x-ndjson": { + "schema": { + "type": "string" + }, + "example": "{\"seq\":1,\"timestamp\":\"2026-02-10T12:00:00.000Z\",\"type\":\"system\",\"message\":\"Server started\"}\n{\"seq\":2,\"timestamp\":\"2026-02-10T12:00:01.000Z\",\"type\":\"sign\",\"message\":\"Sign request received\",\"dataHash\":\"...\",\"dataBytes\":1234,\"data\":{\"kind\":1}}\n" + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + } + } + } + }, "/api/auth/status": { "get": { "operationId": "getAuthStatus", @@ -3090,6 +3261,120 @@ "error" ] }, + "JsonValue": { + "description": "Any JSON value." + }, + "UiEventLogEntry": { + "type": "object", + "properties": { + "seq": { + "type": "integer", + "description": "Monotonic sequence number (primary identifier)." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp for the entry (derived from `createdAtMs`)." + }, + "createdAtMs": { + "type": "integer", + "description": "Milliseconds since epoch when the entry was persisted." + }, + "type": { + "type": "string", + "description": "Log entry type/category." + }, + "message": { + "type": "string", + "description": "Human-readable summary message." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "ISO timestamp (alias of `createdAt`, used by the UI)." + }, + "id": { + "type": "string", + "description": "Stringified `seq` (used by the UI as a stable id)." + }, + "dataHash": { + "type": [ + "string", + "null" + ], + "pattern": "^[0-9a-f]{64}$", + "description": "Content hash of the persisted payload (if present)." + }, + "dataPreview": { + "$ref": "#/components/schemas/JsonValue", + "description": "Small preview of the persisted payload (may be truncated)." + }, + "dataBytes": { + "type": [ + "integer", + "null" + ], + "description": "Size in bytes of the original serialized payload (if present)." + } + }, + "required": [ + "seq", + "createdAt", + "createdAtMs", + "type", + "message", + "timestamp", + "id", + "dataHash", + "dataPreview", + "dataBytes" + ] + }, + "UiEventLogListResponse": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UiEventLogEntry" + } + }, + "nextBeforeSeq": { + "type": [ + "integer", + "null" + ], + "description": "Cursor for the next page (use as `beforeSeq`), or null if no more entries." + } + }, + "required": [ + "entries", + "nextBeforeSeq" + ] + }, + "UiEventLogBlobResponse": { + "type": "object", + "properties": { + "hash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "Content hash of the persisted payload." + }, + "data": { + "$ref": "#/components/schemas/JsonValue", + "description": "Full persisted payload." + }, + "byteLength": { + "type": "integer", + "description": "Byte length of the stored JSON blob." + } + }, + "required": [ + "hash", + "data", + "byteLength" + ] + }, "SignRequest": { "type": "object", "oneOf": [ diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 6f20907..9dac0f9 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -174,6 +174,142 @@ paths: type: string example: Service temporarily unavailable + /api/event-log: + get: + operationId: listUiEventLog + summary: List persisted UI event log entries + description: | + Lists entries from the persisted UI event log (database mode only). + + Notes: + - Results are returned in reverse chronological order (`seq` descending). + - Use `beforeSeq` to paginate older entries. + - Use `types` (comma-separated) to filter by event type. + tags: + - Event Log + parameters: + - name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 500 + default: 200 + description: Maximum number of entries to return (clamped to 1..500). + - name: beforeSeq + in: query + required: false + schema: + type: integer + minimum: 1 + description: Return entries with `seq < beforeSeq` (pagination cursor). + - name: types + in: query + required: false + schema: + type: string + description: Comma-separated list of event types to include (e.g., `sign,error`). + responses: + '200': + description: Event log entries + content: + application/json: + schema: + $ref: '#/components/schemas/UiEventLogListResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /api/event-log/blob/{hash}: + get: + operationId: getUiEventLogBlob + summary: Fetch a persisted UI event log payload by hash + description: | + Fetches the full JSON payload for a persisted UI event log entry by content hash (database mode only). + + The UI typically loads this lazily when a log entry is expanded. + tags: + - Event Log + parameters: + - name: hash + in: path + required: true + schema: + type: string + pattern: '^[0-9a-f]{64}$' + description: SHA-256 hex hash of the persisted payload. + responses: + '200': + description: Event log payload + content: + application/json: + schema: + $ref: '#/components/schemas/UiEventLogBlobResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + + /api/event-log/export: + get: + operationId: exportUiEventLog + summary: Export persisted UI event log as NDJSON + description: | + Exports the persisted UI event log as newline-delimited JSON (NDJSON), one entry per line (database mode only). + + Query options: + - `sinceSeq` (default 1): include entries with `seq >= sinceSeq`. + - `untilSeq`: include entries with `seq <= untilSeq`. + - `types`: comma-separated list of event types to include. + tags: + - Event Log + parameters: + - name: sinceSeq + in: query + required: false + schema: + type: integer + minimum: 1 + default: 1 + description: Lowest `seq` to include (inclusive). + - name: untilSeq + in: query + required: false + schema: + type: integer + minimum: 1 + description: Highest `seq` to include (inclusive). + - name: types + in: query + required: false + schema: + type: string + description: Comma-separated list of event types to include (e.g., `sign,error`). + responses: + '200': + description: | + NDJSON export stream. Each line is a JSON object: + `{ seq, timestamp, type, message, dataHash, dataBytes, data? }` + content: + application/x-ndjson: + schema: + type: string + example: | + {"seq":1,"timestamp":"2026-02-10T12:00:00.000Z","type":"system","message":"Server started"} + {"seq":2,"timestamp":"2026-02-10T12:00:01.000Z","type":"sign","message":"Sign request received","dataHash":"...","dataBytes":1234,"data":{"kind":1}} + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /api/auth/status: get: operationId: getAuthStatus @@ -2002,6 +2138,89 @@ components: required: - error + JsonValue: + description: Any JSON value. + + UiEventLogEntry: + type: object + properties: + seq: + type: integer + description: Monotonic sequence number (primary identifier). + createdAt: + type: string + format: date-time + description: ISO timestamp for the entry (derived from `createdAtMs`). + createdAtMs: + type: integer + description: Milliseconds since epoch when the entry was persisted. + type: + type: string + description: Log entry type/category. + message: + type: string + description: Human-readable summary message. + timestamp: + type: string + format: date-time + description: ISO timestamp (alias of `createdAt`, used by the UI). + id: + type: string + description: Stringified `seq` (used by the UI as a stable id). + dataHash: + type: ["string", "null"] + pattern: '^[0-9a-f]{64}$' + description: Content hash of the persisted payload (if present). + dataPreview: + $ref: '#/components/schemas/JsonValue' + description: Small preview of the persisted payload (may be truncated). + dataBytes: + type: ["integer", "null"] + description: Size in bytes of the original serialized payload (if present). + required: + - seq + - createdAt + - createdAtMs + - type + - message + - timestamp + - id + - dataHash + - dataPreview + - dataBytes + + UiEventLogListResponse: + type: object + properties: + entries: + type: array + items: + $ref: '#/components/schemas/UiEventLogEntry' + nextBeforeSeq: + type: ["integer", "null"] + description: Cursor for the next page (use as `beforeSeq`), or null if no more entries. + required: + - entries + - nextBeforeSeq + + UiEventLogBlobResponse: + type: object + properties: + hash: + type: string + pattern: '^[0-9a-f]{64}$' + description: Content hash of the persisted payload. + data: + $ref: '#/components/schemas/JsonValue' + description: Full persisted payload. + byteLength: + type: integer + description: Byte length of the stored JSON blob. + required: + - hash + - data + - byteLength + SignRequest: type: object oneOf: diff --git a/env.example b/env.example index 0e75ec8..03a0b80 100644 --- a/env.example +++ b/env.example @@ -214,6 +214,17 @@ NIP46_SESSION_RATE_LIMIT_MAX=120 # Diagnostic logging for fingerprint fallbacks (avoid in production unless troubleshooting). # LOG_FINGERPRINT_FALLBACK=false +# ============================================================================= +# UI EVENT LOG (DB MODE ONLY) (ADVANCED) +# ============================================================================= +# By default, `/ping/*` messages are suppressed from the persisted UI event log to avoid runaway growth +# on long-running deployments. Enable only when debugging ping behavior. +# UI_EVENT_LOG_INCLUDE_PINGS=false +# +# Optional: auto-delete older persisted UI event log entries (and unreferenced payload blobs). +# Set to a positive integer number of days. Example: keep 30 days of history. +# UI_EVENT_LOG_RETENTION_DAYS=30 + # ============================================================================= # MANAGED INSTALL FLAGS (ADVANCED) # ============================================================================= diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index 5ba0d6e..a0a3072 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -1005,10 +1005,11 @@ const Signer = forwardRef(({ initialData, authHeaders const handleClearLogs = useCallback(() => { // Audit log is persisted server-side; clearing only resets the current view. + // New real-time events will continue streaming in via WebSocket. + // Reload the first page so oldestSeq is repopulated and "load older" works again. setLogs([]); setOldestSeq(null); setHasMoreHistory(false); - // Re-load the first page so oldestSeq is repopulated and "load older" works again. void loadInitialHistory(); }, [loadInitialHistory]); diff --git a/frontend/components/ui/event-log.tsx b/frontend/components/ui/event-log.tsx index 2a13a9b..07af788 100644 --- a/frontend/components/ui/event-log.tsx +++ b/frontend/components/ui/event-log.tsx @@ -3,7 +3,8 @@ import { IconButton } from "./icon-button"; import { StatusIndicator } from "./status-indicator"; import { Badge } from "./badge"; import { Button } from "./button"; -import { Trash2, ChevronDown, ChevronUp, Filter, X, Download } from "lucide-react"; +import { Trash2, ChevronDown, ChevronUp, Filter, X, Download, HelpCircle } from "lucide-react"; +import { Tooltip } from "./tooltip"; import { cn } from "../../lib/utils"; import { LogEntry, type LogEntryData } from "./log-entry"; @@ -215,6 +216,16 @@ export const EventLog = memo(({ {activeFilters.size} filter{activeFilters.size !== 1 ? 's' : ''} )} +
e.stopPropagation()}> + } + content={ +

Logs are persisted server-side in DB mode. Use the filter to narrow by event type. Clearing resets your current view — new events will continue to appear in real time.

+ } + /> +
e.stopPropagation()} className="flex-shrink-0"> {actions} @@ -293,9 +304,6 @@ export const EventLog = memo(({ > {loadingOlder ? 'Loading…' : hasMore ? 'Load older' : 'No more history'} - - History is persisted server-side in DB mode. Clearing only resets the current view. -
) : null} diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index bfa1d9f..1ccd38f 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -611,7 +611,17 @@ const PeerList: React.FC = ({ } Peer List - +
e.stopPropagation()}> + } + content={ +

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

+ } + /> +
+ {/* Status indicators */}
{ expect(typeof data2.originalSha256).toBe('string') expect(typeof data2.preview).toBe('string') }) + + test('prunes entries older than a retention cutoff and deletes orphan blobs', () => { + const mem = new Database(':memory:') + ensureUiEventLogSchema(mem) + const store = createUiEventLogStore(mem) + + const insert = mem.prepare(` + INSERT INTO ui_event_log_entries ( + created_at_ms, type, message, data_hash, data_preview, data_bytes, source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `) + + const now = Date.now() + const oldMs = now - 10 * 86400000 + const keepMs = now - 1 * 86400000 + + // Old entry with a blob. + const blobJson = JSON.stringify({ ok: true }) + const blobHash = 'a'.repeat(64) + mem.prepare('INSERT INTO ui_event_log_blobs (hash, json, byte_length) VALUES (?, ?, ?)').run(blobHash, blobJson, blobJson.length) + insert.run(oldMs, 'info', 'old', blobHash, blobJson, blobJson.length, null) + + // Newer entry with the same blob (should keep blob after pruning). + insert.run(keepMs, 'info', 'new', blobHash, blobJson, blobJson.length, null) + + // Orphan blob (should be removed by prune). + const orphanHash = 'b'.repeat(64) + mem.prepare('INSERT INTO ui_event_log_blobs (hash, json, byte_length) VALUES (?, ?, ?)').run(orphanHash, blobJson, blobJson.length) + + const beforeEntries = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_entries').get() as { c: number } + const beforeBlobs = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_blobs').get() as { c: number } + expect(beforeEntries.c).toBe(2) + expect(beforeBlobs.c).toBe(2) + + const result = store.prune({ retentionDays: 2 }) + expect(result).toBeTruthy() + expect((result as any).deletedEntries).toBe(1) + + const afterEntries = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_entries').get() as { c: number } + const afterBlobs = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_blobs').get() as { c: number } + expect(afterEntries.c).toBe(1) + expect(afterBlobs.c).toBe(1) + }) }) diff --git a/src/db/ui-event-log.ts b/src/db/ui-event-log.ts index 2b4d8dc..acbeb13 100644 --- a/src/db/ui-event-log.ts +++ b/src/db/ui-event-log.ts @@ -38,6 +38,12 @@ export type UiEventLogExportRow = { data: unknown | null } +export type UiEventLogPruneResult = { + cutoffMs: number + deletedEntries: number + deletedBlobs: number +} + function safeJsonStringify(value: unknown): string | null { if (value === undefined) return null try { @@ -220,6 +226,15 @@ export function createUiEventLogStore(dbConn: Database) { const selectBlob = dbConn.prepare('SELECT json, byte_length FROM ui_event_log_blobs WHERE hash = ?') + const deleteOldEntries = dbConn.prepare('DELETE FROM ui_event_log_entries WHERE created_at_ms < ?') + const deleteOrphanBlobs = dbConn.prepare(` + DELETE FROM ui_event_log_blobs + WHERE NOT EXISTS ( + SELECT 1 FROM ui_event_log_entries e + WHERE e.data_hash = ui_event_log_blobs.hash + ) + `) + return { append(entry: UiEventLogStreamEntry): { seq: number; dataHash: string | null } { const nowMs = Date.now() @@ -400,6 +415,26 @@ export function createUiEventLogStore(dbConn: Database) { const nextAfterSeq = rows.length === limit ? rows[rows.length - 1].seq : null return { rows, nextAfterSeq } } + + , + + prune(opts?: { retentionDays?: number }): UiEventLogPruneResult | null { + const retentionDays = opts?.retentionDays + if (typeof retentionDays !== 'number' || !Number.isFinite(retentionDays) || retentionDays <= 0) return null + + // Keep rows newer than cutoff. + const cutoffMs = Date.now() - Math.floor(retentionDays * 86400000) + if (!Number.isFinite(cutoffMs) || cutoffMs <= 0) return null + + // Delete in two phases: entries first (to avoid FK issues), then orphan blobs. + const r1 = deleteOldEntries.run(cutoffMs) + const r2 = deleteOrphanBlobs.run() + return { + cutoffMs, + deletedEntries: Number(r1.changes) || 0, + deletedBlobs: Number(r2.changes) || 0, + } + } } } @@ -409,3 +444,4 @@ export const appendUiEventLogEntry = defaultStore.append export const listUiEventLogEntries = defaultStore.list export const getUiEventLogBlob = defaultStore.getBlob export const exportUiEventLogChunk = defaultStore.exportChunk +export const pruneUiEventLog = defaultStore.prune diff --git a/src/node/manager.ts b/src/node/manager.ts index dbd55b8..2e7209c 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -77,6 +77,15 @@ const SELF_ECHO_TIMEOUT_MS = (() => { return 10000; })(); +// By default, suppress extremely high-volume ping request/response entries from the UI event log. +// Ping traffic is still used for peer status tracking and connectivity monitoring. +const UI_EVENT_LOG_INCLUDE_PINGS = (() => { + const raw = process.env.UI_EVENT_LOG_INCLUDE_PINGS; + if (!raw) return false; + const normalized = raw.trim().toLowerCase(); + return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on'; +})(); + // WebSocket ready state constants const READY_STATE_OPEN = 1; @@ -1572,7 +1581,11 @@ export function setupNodeEventListeners( timestamp: new Date().toLocaleTimeString(), id: Math.random().toString(36).substring(2, 11) }); - + } + + // Suppress ping logs by default to avoid runaway log growth over long-running deployments. + if (!UI_EVENT_LOG_INCLUDE_PINGS) { + return } } @@ -1607,6 +1620,7 @@ export function setupNodeEventListeners( } else if (tag.startsWith('/ecdh/')) { addServerLog('ecdh', `ECDH event: ${tag}`, msg); } else if (tag.startsWith('/ping/')) { + if (!UI_EVENT_LOG_INCLUDE_PINGS) return const selfPing = isSelfPing(messageData, groupCred, shareCred); if (!selfPing) { addServerLog('bifrost', `Ping event: ${tag}`, msg); diff --git a/src/server.ts b/src/server.ts index 90086b6..d720965 100644 --- a/src/server.ts +++ b/src/server.ts @@ -377,6 +377,28 @@ async function initializeDatabase(): Promise { return null } } + + // Optional retention: prune old persisted UI event log entries on an interval. + const rawRetentionDays = process.env.UI_EVENT_LOG_RETENTION_DAYS + const retentionDays = rawRetentionDays ? Number.parseInt(rawRetentionDays, 10) : NaN + if (Number.isFinite(retentionDays) && retentionDays > 0) { + const runPrune = () => { + try { + const result = uiLog.pruneUiEventLog?.({ retentionDays }) + if (result && process.env.NODE_ENV !== 'production') { + console.log(`[ui-event-log] pruned ${result.deletedEntries} entries and ${result.deletedBlobs} blobs (retentionDays=${retentionDays})`) + } + } catch (e) { + if (process.env.NODE_ENV !== 'production') { + console.warn('[ui-event-log] prune failed:', e instanceof Error ? e.message : String(e)) + } + } + } + + runPrune() + const interval = setInterval(runPrune, 6 * 60 * 60 * 1000) + ;(interval as any).unref?.() + } } catch (e: any) { console.error('⚠️ Failed to enable UI event log persistence:', e?.message || e) } diff --git a/tests/routes/event-log.db-mode.spec.ts b/tests/routes/event-log.db-mode.spec.ts new file mode 100644 index 0000000..54231cb --- /dev/null +++ b/tests/routes/event-log.db-mode.spec.ts @@ -0,0 +1,165 @@ +import { describe, expect, test } from 'bun:test'; +import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; + +describe('Event log routes (DB mode)', () => { + test('lists entries, paginates by beforeSeq, and filters by types', () => { + const script = ` + import { mkdtempSync } from 'fs'; + import os from 'os'; + import path from 'path'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'false'; + + // Isolate DB for this script (ui-event-log uses the default DB connection). + const tmp = mkdtempSync(path.join(os.tmpdir(), 'ui-event-log-db-')); + process.env.DB_PATH = tmp; + + const { appendUiEventLogEntry } = await import(root + 'src/db/ui-event-log.ts'); + const { handleEventLogRoute } = await import(root + 'src/routes/event-log.ts'); + + appendUiEventLogEntry({ type: 'info', message: 'one', data: { a: 1 }, timestamp: new Date().toISOString(), id: 'seed1' }); + appendUiEventLogEntry({ type: 'sign', message: 'two', data: { kind: 1 }, timestamp: new Date().toISOString(), id: 'seed2' }); + appendUiEventLogEntry({ type: 'error', message: 'three', data: { ok: false }, timestamp: new Date().toISOString(), id: 'seed3' }); + + const context = {} as any; + + const req1 = new Request('http://localhost/api/event-log?limit=2'); + const res1 = await handleEventLogRoute(req1, new URL(req1.url), context, { authenticated: true, userId: 1 }); + const body1 = await res1.json(); + + const req2 = new Request('http://localhost/api/event-log?limit=10&beforeSeq=' + body1.nextBeforeSeq); + const res2 = await handleEventLogRoute(req2, new URL(req2.url), context, { authenticated: true, userId: 1 }); + const body2 = await res2.json(); + + const req3 = new Request('http://localhost/api/event-log?limit=10&types=sign'); + const res3 = await handleEventLogRoute(req3, new URL(req3.url), context, { authenticated: true, userId: 1 }); + const body3 = await res3.json(); + + console.log('@@RESULT@@' + JSON.stringify({ + status1: res1.status, + body1, + status2: res2.status, + body2, + status3: res3.status, + body3, + })); + process.exit(0); + `; + + const out = runRouteScript(script); + + expect(out.status1).toBe(200); + expect(out.body1?.entries?.length).toBe(2); + // Descending seq + expect(Number(out.body1.entries[0].seq)).toBeGreaterThan(Number(out.body1.entries[1].seq)); + expect(typeof out.body1.nextBeforeSeq).toBe('number'); + + expect(out.status2).toBe(200); + expect(out.body2?.entries?.length).toBe(1); + + expect(out.status3).toBe(200); + expect(out.body3?.entries?.length).toBe(1); + expect(out.body3.entries[0].type).toBe('sign'); + }); + + test('blob endpoint returns payload by hash and 404s for invalid hash', () => { + const script = ` + import { mkdtempSync } from 'fs'; + import os from 'os'; + import path from 'path'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'false'; + const tmp = mkdtempSync(path.join(os.tmpdir(), 'ui-event-log-blob-')); + process.env.DB_PATH = tmp; + + const { appendUiEventLogEntry } = await import(root + 'src/db/ui-event-log.ts'); + const { handleEventLogRoute } = await import(root + 'src/routes/event-log.ts'); + + const seeded = appendUiEventLogEntry({ + type: 'info', + message: 'seed', + data: { hello: 'world' }, + timestamp: new Date().toISOString(), + id: 'seed' + }); + + const context = {} as any; + + const okReq = new Request('http://localhost/api/event-log/blob/' + seeded.dataHash); + const okRes = await handleEventLogRoute(okReq, new URL(okReq.url), context, { authenticated: true, userId: 1 }); + const okBody = await okRes.json(); + + const badReq = new Request('http://localhost/api/event-log/blob/not-a-hash'); + const badRes = await handleEventLogRoute(badReq, new URL(badReq.url), context, { authenticated: true, userId: 1 }); + const badBody = await badRes.json(); + + console.log('@@RESULT@@' + JSON.stringify({ + okStatus: okRes.status, + okBody, + badStatus: badRes.status, + badBody, + })); + process.exit(0); + `; + + const out = runRouteScript(script); + expect(out.okStatus).toBe(200); + expect(out.okBody?.hash).toBeDefined(); + expect(out.okBody?.data?.hello).toBe('world'); + expect(typeof out.okBody?.byteLength).toBe('number'); + + expect(out.badStatus).toBe(404); + expect(out.badBody?.error).toContain('Not found'); + }); + + test('export endpoint streams NDJSON and respects since/until', () => { + const script = ` + import { mkdtempSync } from 'fs'; + import os from 'os'; + import path from 'path'; + const root = ${JSON.stringify(PROJECT_ROOT)}; + + process.env.NODE_ENV = 'test'; + process.env.HEADLESS = 'false'; + const tmp = mkdtempSync(path.join(os.tmpdir(), 'ui-event-log-export-')); + process.env.DB_PATH = tmp; + + const { appendUiEventLogEntry } = await import(root + 'src/db/ui-event-log.ts'); + const { handleEventLogRoute } = await import(root + 'src/routes/event-log.ts'); + + const e1 = appendUiEventLogEntry({ type: 'info', message: 'one', data: { n: 1 }, timestamp: new Date().toISOString(), id: 'seed1' }); + const e2 = appendUiEventLogEntry({ type: 'sign', message: 'two', data: { n: 2 }, timestamp: new Date().toISOString(), id: 'seed2' }); + const e3 = appendUiEventLogEntry({ type: 'error', message: 'three', data: { n: 3 }, timestamp: new Date().toISOString(), id: 'seed3' }); + + const since = e2.seq; + const until = e3.seq; + + const context = {} as any; + const req = new Request('http://localhost/api/event-log/export?sinceSeq=' + since + '&untilSeq=' + until); + const res = await handleEventLogRoute(req, new URL(req.url), context, { authenticated: true, userId: 1 }); + const text = await res.text(); + const lines = text.trim().split('\\n').filter(Boolean); + const parsed = lines.map(l => JSON.parse(l)); + + console.log('@@RESULT@@' + JSON.stringify({ + status: res.status, + contentType: res.headers.get('Content-Type'), + count: parsed.length, + seqs: parsed.map(p => p.seq), + types: parsed.map(p => p.type), + })); + process.exit(0); + `; + + const out = runRouteScript(script); + expect(out.status).toBe(200); + expect(out.contentType).toContain('application/x-ndjson'); + expect(out.count).toBe(2); + expect(out.seqs[0]).toBeLessThan(out.seqs[1]); + }); +}); + From 699d2562410807213f168f8f1e40613cf6efa417 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 13 Feb 2026 11:42:19 -0600 Subject: [PATCH 20/69] fix type --- docs/openapi/openapi.json | 4 ++++ docs/openapi/openapi.yaml | 2 ++ src/db/ui-event-log.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 2b5d74d..0bbca29 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -81,6 +81,10 @@ { "name": "Onboarding", "description": "First-run onboarding and admin validation (database mode)" + }, + { + "name": "Event Log", + "description": "Persisted UI event log endpoints (database mode only)" } ], "paths": { diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 9dac0f9..db5e2ca 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -2719,3 +2719,5 @@ tags: description: Authenticated user endpoints (database mode) - name: Onboarding description: First-run onboarding and admin validation (database mode) + - name: Event Log + description: Persisted UI event log endpoints (database mode only) \ No newline at end of file diff --git a/src/db/ui-event-log.test.ts b/src/db/ui-event-log.test.ts index 5ca7a93..81426ec 100644 --- a/src/db/ui-event-log.test.ts +++ b/src/db/ui-event-log.test.ts @@ -116,8 +116,8 @@ describe('ui-event-log store', () => { expect(beforeBlobs.c).toBe(2) const result = store.prune({ retentionDays: 2 }) - expect(result).toBeTruthy() - expect((result as any).deletedEntries).toBe(1) + if (!result) throw new Error('expected prune to return a result') + expect(result.deletedEntries).toBe(1) const afterEntries = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_entries').get() as { c: number } const afterBlobs = mem.prepare('SELECT COUNT(*) as c FROM ui_event_log_blobs').get() as { c: number } From 9c9405a8886d1077d3934b1efd022af979c1128b Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 13 Feb 2026 13:02:41 -0600 Subject: [PATCH 21/69] add alert on log export failure --- frontend/components/Signer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index a0a3072..e219b56 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -614,6 +614,7 @@ const Signer = forwardRef(({ initialData, authHeaders setTimeout(() => window.URL.revokeObjectURL(url), 500); } catch (error) { console.warn('Log export failed', error); + window.alert('Log export failed'); } finally { setDownloadingLogs(false); } From 12698acc76885b8f187f1817d584d13418675984 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 13 Feb 2026 16:06:49 -0600 Subject: [PATCH 22/69] docs: add event-log endpoints to API reference --- llm/context/API_REFERENCE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/llm/context/API_REFERENCE.md b/llm/context/API_REFERENCE.md index 4859228..d47792b 100644 --- a/llm/context/API_REFERENCE.md +++ b/llm/context/API_REFERENCE.md @@ -111,6 +111,11 @@ NIP-46 extended API (DB only) - `GET /api/nip46/requests`, `POST /api/nip46/requests`, `DELETE /api/nip46/requests` (request queue). - `POST /api/nip46/connect` (process `nostrconnect://` URI). +Event log (DB only) +- `GET /api/event-log?limit=200&beforeSeq=&types=` (list persisted entries with optional cursor and type filtering). +- `GET /api/event-log/blob/` (retrieve a large payload by content hash). +- `GET /api/event-log/export?sinceSeq=&untilSeq=&types=` (export entries as NDJSON). + Non-API WebSocket - `GET /` with `Upgrade: websocket` is the internal relay WebSocket; origin and rate limits apply. From 0df599ae53c75dd7e676968edf507012ebed527a Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 18:46:46 -0600 Subject: [PATCH 23/69] playwright e2e tests --- .github/workflows/ci.yml | 24 +- .github/workflows/release.yml | 6 + bun.lock | 11 +- frontend/components/ui/peer-list.tsx | 3 +- frontend/types/index.ts | 1 + llm/implementation/e2e-smoke-tests.md | 371 ++++++++++++++++++ .../node-lifecycle-implementation.md | 2 +- llm/implementation/umbrel-implementation.md | 6 +- package.json | 6 + playwright-report/index.html | 85 ++++ playwright.config.ts | 43 ++ scripts/release.sh | 6 +- src/class/relay.ts | 13 +- src/routes/env.ts | 14 + src/routes/utils.ts | 6 +- test-results/.last-run.json | 4 + tests/e2e/cosigner.mjs | 61 +++ tests/e2e/global-setup.ts | 284 ++++++++++++++ tests/e2e/global-teardown.ts | 48 +++ tests/e2e/specs/01-auth.e2e.ts | 117 ++++++ tests/e2e/specs/02-status-peers.e2e.ts | 98 +++++ tests/e2e/specs/03-nip44-nip04.e2e.ts | 140 +++++++ tests/e2e/specs/04-sign.e2e.ts | 130 ++++++ tests/e2e/specs/05-admin.e2e.ts | 138 +++++++ tests/e2e/specs/06-event-log.e2e.ts | 86 ++++ tests/e2e/specs/07-env.e2e.ts | 85 ++++ tests/e2e/specs/08-ui.e2e.ts | 124 ++++++ tests/e2e/state.ts | 51 +++ tests/routes/helpers/script-runner.ts | 56 ++- 29 files changed, 2003 insertions(+), 16 deletions(-) create mode 100644 llm/implementation/e2e-smoke-tests.md create mode 100644 playwright-report/index.html create mode 100644 playwright.config.ts create mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/cosigner.mjs create mode 100644 tests/e2e/global-setup.ts create mode 100644 tests/e2e/global-teardown.ts create mode 100644 tests/e2e/specs/01-auth.e2e.ts create mode 100644 tests/e2e/specs/02-status-peers.e2e.ts create mode 100644 tests/e2e/specs/03-nip44-nip04.e2e.ts create mode 100644 tests/e2e/specs/04-sign.e2e.ts create mode 100644 tests/e2e/specs/05-admin.e2e.ts create mode 100644 tests/e2e/specs/06-event-log.e2e.ts create mode 100644 tests/e2e/specs/07-env.e2e.ts create mode 100644 tests/e2e/specs/08-ui.e2e.ts create mode 100644 tests/e2e/state.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2303ea..1245aae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ jobs: - name: Type check run: bun run tsc --noEmit + - name: Run backend tests + run: bun run test:unit + - name: Build frontend run: bun run build @@ -71,16 +74,33 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Run security audit run: | - bun audit || true # Don't fail on audit issues for now + for attempt in 1 2 3; do + if bun audit; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + echo "bun audit failed (attempt $attempt), retrying..." + sleep 5 + fi + done + echo "bun audit failed after retries" + exit 1 - name: Check for secrets uses: trufflesecurity/trufflehog@main with: path: ./ extra_args: --debug --only-verified - continue-on-error: true docker: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2ddea..1627643 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,12 @@ jobs: echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT echo "version_number=${NEW_VERSION#v}" >> $GITHUB_OUTPUT + - name: Type check + run: bun run typecheck + + - name: Run backend tests + run: bun run test:unit + - name: Build application run: bun run build diff --git a/bun.lock b/bun.lock index 0dc4d79..079d0ee 100644 --- a/bun.lock +++ b/bun.lock @@ -26,6 +26,7 @@ "yaml": "^2.8.1", }, "devDependencies": { + "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", @@ -184,6 +185,8 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -430,7 +433,7 @@ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -618,6 +621,10 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], + + "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -838,6 +845,8 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 1ccd38f..96adf38 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -611,10 +611,11 @@ const PeerList: React.FC = ({ } Peer List -
e.stopPropagation()}> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()}> } content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

diff --git a/frontend/types/index.ts b/frontend/types/index.ts index 0563d44..a7d1627 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -124,6 +124,7 @@ export interface UpdateInfo { updateAvailable: boolean; releaseUrl?: string; checkedAt?: string; + source?: 'github-release' | 'github-tags'; error?: string; } diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md new file mode 100644 index 0000000..5c40865 --- /dev/null +++ b/llm/implementation/e2e-smoke-tests.md @@ -0,0 +1,371 @@ +# E2E Smoke Test Suite (Playwright – DB Mode) + +Last verified: 2026-02-20 +Test count: 62 (54 API + 8 UI) — all passing + +## Purpose + +The Playwright smoke test suite exercises igloo-server end-to-end in **database mode** (the default, `HEADLESS=false`). It starts a real server process, spins up a live FROSTR co-signer, completes the full onboarding flow, and then runs two categories of tests: + +- **API project** (`01`–`07`): Pure HTTP request-context tests — no browser. Cover auth, status, peers, NIP-44, NIP-04, signing, admin, event log, and credential management. +- **UI project** (`08`): Headless Chrome browser tests. Cover the login page, tab navigation, and the Event Log section embedded in the Signer tab. + +## Running the Tests + +```bash +# Full suite (both projects) +npx playwright test + +# API-only (faster, no browser dependency) +npx playwright test --project=api + +# UI-only +npx playwright test --project=ui + +# Single spec file +npx playwright test tests/e2e/specs/04-sign.e2e.ts + +# HTML report (opens automatically after a run that had failures) +npx playwright show-report +``` + +Prerequisites: +- `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). +- `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). +- No other process listening on port 18002. + +## File Structure + +``` +tests/e2e/ +├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json +├── global-teardown.ts # SIGTERMs both processes, deletes temp dir +├── state.ts # loadState() helper — reads JSON written by global-setup +├── cosigner.mjs # Minimal FROSTR co-signer subprocess (node/ESM) +└── specs/ + ├── 01-auth.e2e.ts + ├── 02-status-peers.e2e.ts + ├── 03-nip44-nip04.e2e.ts + ├── 04-sign.e2e.ts + ├── 05-admin.e2e.ts + ├── 06-event-log.e2e.ts + ├── 07-env.e2e.ts + └── 08-ui.e2e.ts + +playwright.config.ts # Project definitions: "api" (01–07), "ui" (08) +``` + +## Global Setup (`global-setup.ts`) + +The setup runs **once** before all tests and does the following in order: + +### 1. Generate a 2-of-2 FROSTR keyset + +```typescript +const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); +``` + +A **2-of-2** (not 2-of-3) scheme is used deliberately: with exactly two shares, the one connected co-signer is always sufficient to reach threshold without any ambiguity about which peer is needed. `TEST_NSEC_HEX` is a fixed 32-byte private key so the keyset is deterministic across runs. + +### 2. Start igloo-server + +The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, logFile)`. Key env overrides: + +| Variable | Value | Reason | +|---|---|---| +| `HOST_PORT` | `18002` | Fixed test port | +| `HOST_NAME` | `127.0.0.1` | Loopback only | +| `ADMIN_SECRET` | `SmokeTestAdmin1` | Deterministic | +| `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | +| `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | +| `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | +| `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | +| `FROSTR_SIGN_TIMEOUT` | `15000` | Allow 15 s for threshold signing | +| `GROUP_CRED` | `''` | Clear any `.env` credential interference | +| `SHARE_CRED` | `''` | Clear any `.env` credential interference | +| `RELAYS` | `''` | Clear any `.env` relay interference | +| `NODE_ENV` | `test` | Suppresses some production-only behaviors | + +**Critical**: Bun automatically loads `.env` from the current directory into `process.env`. If the developer's `.env` contains stale `GROUP_CRED`/`SHARE_CRED`/`RELAYS` values from a different port, the server would connect its bifrost node to the wrong relay, making signing always time out. The empty-string overrides above force those variables to be blank regardless of what `.env` contains. + +### 3. Complete onboarding + +``` +POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) +POST /api/onboarding/setup (creates admin user with username + password) +POST /api/auth/login → sessionId +``` + +### 4. Set FROSTR credentials + +``` +POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } +``` + +In DB mode, credentials are stored per-user encrypted in SQLite. The server then creates the in-memory bifrost node and polls `GET /api/status` until `nodeActive === true`. + +### 5. Start co-signer + +```bash +node tests/e2e/cosigner.mjs ws://127.0.0.1:18002 +``` + +`cosigner.mjs` creates a bifrost node using `@frostr/igloo-core` directly (no igloo-cli TUI). The server holds `shareCredentials[0]`; the co-signer holds `shareCredentials[1]`. Both connect to the server's built-in Nostr relay at `ws://127.0.0.1:18002`. + +### 6. Signing readiness probe + +Up to 5 attempts (3 s apart) to POST a 32-byte hex message to `/api/sign`. Success confirms the threshold is reachable and the relay subscription is active on both sides. Setup aborts if signing never succeeds. + +### 7. Create a persistent API key + +`POST /api/admin/api-keys { label: 'smoke-test-key' }` — the returned token is saved in `state.json` as `apiKey` and used in tests that verify API key authentication. + +### 8. Write shared state + +Everything is serialized to `$TMPDIR/igloo-smoke-test/state.json` and the path is exported as `SMOKE_STATE_FILE`. Every spec file calls `loadState()` at module level to read this file. + +## Shared State (`state.ts`) + +`loadState()` reads `SMOKE_STATE_FILE`. During Playwright's test discovery phase (when `--list` is run or the config is imported without a server running) `SMOKE_STATE_FILE` is not set, so the function returns a harmless stub with empty strings. Tests only execute after global-setup has populated the real state. + +```typescript +interface SmokeTestState { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; // live admin session from global-setup login + apiKey: string | null; // DB-backed API key token + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; // [0] = server share, [1] = cosigner share + groupPubkeyHex: string; // x-only (no 02/03 prefix) + adminUsername: string; + adminPassword: string; + adminSecret: string; +} +``` + +## Spec Coverage + +### `01-auth.e2e.ts` — Authentication + +- `GET /api/auth/status` returns available auth methods +- `POST /api/auth/login` — valid credentials return `sessionId` +- `POST /api/auth/login` — wrong/unknown password returns 401 +- `GET /api/peers` — no auth returns 401 *(uses `/api/peers`, not `/api/status` — see design decisions below)* +- `GET /api/status` — valid session and API key (X-API-Key and Bearer formats) return 200 +- `GET /api/peers` — invalid API key returns 401 +- `POST /api/auth/logout` — invalidates session; subsequent `GET /api/peers` returns 401 + +### `02-status-peers.e2e.ts` — Status and Peers + +- `GET /api/status` — publicly accessible without auth (intentional design; returns 200) +- `GET /api/status` — with session returns full node info: `serverRunning`, `nodeActive`, `health`, `relayCount`, `timestamp` +- `GET /api/status` — health object has `isConnected`, `consecutiveConnectivityFailures` +- `GET /api/peers` — 401 without auth +- `GET /api/peers` — returns peer list with `peers`, `total`, `online` +- `GET /api/peers/group` — returns `pubkey` (matches `state.groupPubkeyHex`), `threshold` +- `GET /api/peers/self` — returns own share pubkey + +### `03-nip44-nip04.e2e.ts` — NIP-44 and NIP-04 Encryption + +NIP-44: +- 401 without auth +- Encrypt returns ciphertext +- Encrypt → decrypt round-trips plaintext +- Invalid `peer_pubkey` returns 400 +- Missing `content` returns 400 + +NIP-04: +- 401 without auth +- Encrypt returns ciphertext with IV suffix (NIP-04 format: `?iv=`) +- Encrypt → decrypt round-trips plaintext +- Invalid `peer_pubkey` returns 400 + +Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encrypts to itself for round-trip tests). + +### `04-sign.e2e.ts` — Threshold Signing + +- 401 without auth +- 400 for non-hex message +- 400 for message shorter than 32 bytes +- 400 for missing body +- Signs a 32-byte hex message; response contains `sig` and `pubkey` +- Signs a full Nostr event object (with `id`, `content`, `kind`, `created_at`, `tags`) +- Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing +- 400 for event with invalid pubkey + +Signing tests exercise the complete FROSTR threshold flow: server publishes a sign request over the relay, co-signer responds with a partial signature, server aggregates and returns the final signature. + +### `05-admin.e2e.ts` — Admin Endpoints + +API key management: +- `GET /api/admin/api-keys` returns list (includes key from global-setup) +- `POST /api/admin/api-keys` creates a key (returns 201 with `token`, `id`) +- New API key authenticates successfully +- Revoked API key returns 401: creates key → verify works on `/api/event-log` → revoke → verify 401 on `/api/event-log` + +User management: +- `GET /api/admin/users` returns users list; admin user is present +- `GET /api/admin/whoami` returns `userId` +- Both require auth (401 without) + +### `06-event-log.e2e.ts` — UI Event Log + +- `GET /api/event-log` — 401 without auth +- Returns `{ entries: [...] }` with valid shape (`type`, `message`, `timestamp`) +- Pagination: `?limit=5` returns ≤ 5 entries +- `GET /api/event-log/export` — streams NDJSON (`Content-Type: application/x-ndjson`); each line parses as valid JSON +- Export — 401 without auth + +### `07-env.e2e.ts` — Credential / Env Management + +- `GET /api/env` — 401 without auth +- `GET /api/env` with session — returns `{ hasCredentials: true, ... }` +- `POST /api/env` — invalid `GROUP_CRED` returns 400 +- `POST /api/env` — invalid `SHARE_CRED` returns 400 +- `POST /api/env` — invalid relay URL returns 400 +- `POST /api/env` — without auth returns 401 + +### `08-ui.e2e.ts` — Browser UI (Headless Chrome) + +Login page: +- `/` renders login form (username + password inputs visible) +- Login form fills credentials and reaches the dashboard (tabs visible) + +Authenticated app (each test logs in fresh via `beforeEach`): +- Signer tab is visible after login +- Configure tab is accessible (click navigates, inputs render) +- API Keys tab renders without "Something went wrong" +- Event Log collapsible section (inside Signer tab, not a separate tab) is visible, click-to-expand works, no errors +- Logout button signs out and returns to login form + +Onboarding: +- `/` does not show "Admin Secret" text when DB is already initialized + +## Design Decisions and Gotchas + +### `/api/status` is intentionally public + +`/api/status` bypasses the main authentication check in `src/routes/index.ts`: + +```typescript +const isStatusEndpoint = url.pathname === '/api/status'; +// Auth check skips status: +if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && ...) { +``` + +This is by design — unauthenticated health checks and monitoring probes must be able to reach the status endpoint. Consequently: +- Tests that verify 401 enforcement **must use a different endpoint** (e.g., `GET /api/peers` or `GET /api/event-log`). +- Tests that verify authenticated 200 responses can still use `/api/status` (they pass with or without auth). + +### DB API keys and per-user credential lookup + +Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. + +Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. +Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). + +For this reason: +- The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. +- The "new API key can authenticate" test uses `GET /api/status` (public) which trivially returns 200; this confirms the key is created but does not actually exercise DB-key auth enforcement. + +### Event log export is NDJSON, not JSON + +`GET /api/event-log/export` returns `Content-Type: application/x-ndjson` with one JSON object per line (newline-delimited JSON). Calling `response.json()` on this response fails because the body as a whole is not valid JSON. The test reads the body as text and parses each line individually: + +```typescript +const text = await res.text(); +const lines = text.trim().split('\n').filter(Boolean); +for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); +} +``` + +### `POST /api/env` validates credential format in DB mode + +The DB-mode `POST /api/env` handler (in `src/routes/env.ts`) validates `GROUP_CRED` and `SHARE_CRED` using `validateGroup()` / `validateShare()` from `@frostr/igloo-core` before writing to the `.env` file. Invalid credentials return 400. This validation was added during test development; it was previously only present on the headless `/api/env/shares` path. + +### Event Log is embedded in Signer tab, not a top-level tab + +The application has four top-level tabs: **Signer**, **NIP-46**, **API Keys**, **Recover**. There is no "Event Log" tab. The event log is a collapsible section within the Signer tab rendered as a `div[role="button"]` containing a `Event Log`. The UI test locates it with: + +```typescript +page.locator('[role="button"]:has-text("Event Log")').first() +``` + +### `.env` file interference with test server + +Bun loads `.env` from the current working directory automatically. A developer's `.env` may contain `GROUP_CRED`, `SHARE_CRED`, or `RELAYS` pointing to a production relay or a different port. If these leak into the test server's environment, the server creates a bifrost node at startup using the old credentials (different relay URL), and when the test then POSTs new credentials the server logs "Node already running, skipping restart" and stays connected to the wrong relay. The co-signer connects to the test relay, the server connects elsewhere — signing always times out. + +**Fix**: global-setup passes explicit empty-string overrides for all three variables when spawning the server: +```typescript +GROUP_CRED: '', +SHARE_CRED: '', +RELAYS: '', +``` + +### nostr-tools 2.x REQ filter format + +`@frostr/igloo-core` (which depends on `nostr-tools` 2.x) sends REQ messages in the format: +```json +["REQ", "sub_id", [{"kinds":[20004],"#p":[""]}]] +``` +Note the **array-wrapped filter** as the third element. NIP-01 expects filters as positional arguments: +```json +["REQ", "sub_id", {"kinds":[20004],"#p":[""]}] +``` + +The built-in relay (`src/class/relay.ts`) normalizes this in `_handler`: +```typescript +if (payload.length === 2 && Array.isArray(payload[1])) { + payload = [payload[0], ...payload[1]]; +} +``` + +Without this fix, the server's relay would reject all subscriptions from the bifrost node (logging "bad req: provided filter is not an object") and signing would always time out. + +## Temp Directory Layout + +Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): + +``` +igloo-smoke-test/ +├── db/ # SQLite database files (igloo.db, .session-secret) +├── state.json # Shared test state (pids, session, credentials, etc.) +├── server.log # igloo-server stdout/stderr +└── cosigner.log # co-signer subprocess stdout/stderr +``` + +If a run fails unexpectedly (e.g., setup throws before teardown registers), the temp dir may be left behind. It is safe to delete manually. + +## Adding New Tests + +1. Create `tests/e2e/specs/NN-name.e2e.ts`. +2. Import `loadState` from `../state.js` and call it at module level. +3. Use `state.sessionId` for session-authenticated requests, `state.apiKey` for API key requests. +4. Add the spec to the correct project in `playwright.config.ts` (update `testMatch` if needed, or rely on the `0[1-7]-*.e2e.ts` glob for API specs). +5. If testing a credential-sensitive endpoint in DB mode (peers, env), use `state.sessionId` — DB API keys cannot look up per-user credentials. + +## Files + +| File | Role | +|---|---| +| `tests/e2e/global-setup.ts` | Server lifecycle, onboarding, state serialization | +| `tests/e2e/global-teardown.ts` | SIGTERM + temp dir cleanup | +| `tests/e2e/state.ts` | `SmokeTestState` type and `loadState()` | +| `tests/e2e/cosigner.mjs` | Minimal co-signer subprocess (ESM, no TUI) | +| `tests/e2e/specs/01-auth.e2e.ts` | Auth enforcement, login/logout | +| `tests/e2e/specs/02-status-peers.e2e.ts` | Node status, peer list | +| `tests/e2e/specs/03-nip44-nip04.e2e.ts` | NIP-44 / NIP-04 encrypt+decrypt | +| `tests/e2e/specs/04-sign.e2e.ts` | Threshold Schnorr signing | +| `tests/e2e/specs/05-admin.e2e.ts` | API key CRUD, revocation, user management | +| `tests/e2e/specs/06-event-log.e2e.ts` | Event log pagination and NDJSON export | +| `tests/e2e/specs/07-env.e2e.ts` | Credential/env endpoint validation | +| `tests/e2e/specs/08-ui.e2e.ts` | Headless Chrome SPA smoke tests | +| `playwright.config.ts` | Project config, timeout, reporter, globalSetup/Teardown | +| `src/routes/env.ts` | DB-mode POST validates GROUP_CRED / SHARE_CRED format | +| `src/class/relay.ts` | Normalizes nostr-tools 2.x double-wrapped REQ filters | +| `src/routes/utils.ts` | `ALLOW_LOCALHOST_RELAY` bypass for test relay URLs | diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index f4e19ae..d132d4b 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false` (or `RELAY_ALLOW_BENIGN_SWALLOW`) forces publish errors to surface. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset. Setting either to `false` forces publish errors to surface. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 8c3b8c9..22353b5 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -66,11 +66,11 @@ These values are set in the store compose and expected by the UI flow: ## Operational Notes - Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. -- The Umbrel store uses a pinned digest to avoid tag caching issues; update the digest on each new release. -- `packages/umbrel/igloo/docker-compose.yml` remains a sideload/dev bundle and still points at `:umbrel-dev` without a digest. +- The Umbrel store `docker-compose.yml` intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. +- `packages/umbrel/igloo/docker-compose.yml` is a sideload/dev bundle and also points at `:umbrel-dev` but without a pinned digest. ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update `igloo-server-store/igloo-server/docker-compose.yml` to the new image digest. +2. Update the digest in `igloo-server-store/igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). 3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/package.json b/package.json index aed086c..822551e 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,11 @@ "api:test:get:openapi": "bun scripts/api/test-get-openapi-sweep.ts", "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", + "test:unit": "bun test --max-concurrency=1 src tests/routes", + "test:e2e": "npx playwright test", + "test:e2e:ui": "npx playwright test --project=ui", + "test:e2e:api": "npx playwright test --project=api", + "test:e2e:report": "npx playwright show-report", "typecheck": "tsc --noEmit", "tsc": "tsc --noEmit" }, @@ -58,6 +63,7 @@ "yaml": "^2.8.1" }, "devDependencies": { + "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", diff --git a/playwright-report/index.html b/playwright-report/index.html new file mode 100644 index 0000000..41e3ff2 --- /dev/null +++ b/playwright-report/index.html @@ -0,0 +1,85 @@ + + + + + + + + + Playwright Test Report + + + + +
+ + + \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..384184a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,43 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e/specs', + globalSetup: './tests/e2e/global-setup.ts', + globalTeardown: './tests/e2e/global-teardown.ts', + + // Run all tests sequentially – they share one live server + co-signer process + fullyParallel: false, + workers: 1, + + // Retry once on CI to absorb timing flakes + retries: process.env.CI ? 1 : 0, + + reporter: [ + ['list'], + ['html', { outputFolder: 'playwright-report', open: 'never' }], + ], + + use: { + baseURL: 'http://localhost:18002', + trace: 'on-first-retry', + // Longer default for operations that wait on bifrost relay round-trips + actionTimeout: 15_000, + }, + + projects: [ + // Pure API specs (01–07) – use request context only, no browser + { + name: 'api', + testMatch: ['**/0[1-7]-*.e2e.ts'], + }, + // Browser UI spec (08) – needs a real browser + { + name: 'ui', + testMatch: ['**/08-ui.e2e.ts'], + use: { ...devices['Desktop Chrome'], headless: true }, + }, + ], + + // Global per-test timeout – sign tests can take up to 15 s + timeout: 30_000, +}); diff --git a/scripts/release.sh b/scripts/release.sh index 107ff4f..e161490 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -28,8 +28,12 @@ echo "📥 Pulling latest changes..." git pull origin dev # Run tests and build -echo "🔨 Building project..." +echo "🧪 Running type checks and backend tests..." bun install +bun run typecheck +bun run test:unit + +echo "🔨 Building project..." bun run build echo "✅ Testing server startup..." diff --git a/src/class/relay.ts b/src/class/relay.ts index e849402..f4dab29 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -152,6 +152,12 @@ class RelaySession { switch (verb) { case 'REQ': + // Normalize nostr-tools 2.x format where filters are wrapped in an extra array: + // New format: ["REQ", "sub_id", [{filter1}, {filter2}]] + // NIP-01 format: ["REQ", "sub_id", {filter1}, {filter2}] + if (payload.length === 2 && Array.isArray(payload[1])) { + payload = [payload[0], ...payload[1]] + } const [ id, ...filters ] = sub_schema.parse(payload) return this._onreq(id, filters) case 'EVENT': @@ -185,7 +191,8 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.debug('event failed validation:', event) + this.log.info('event failed validation (id=' + event.id.slice(0, 8) + ' kind=' + event.kind + ')') + this.log.debug('event details:', event) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } @@ -210,7 +217,7 @@ class RelaySession { this.log.client('received subscription request:', sub_id) this.log.debug('filters:', filters) // Add the subscription to our set. - this.addSub(sub_id, filters) + this.addSub(sub_id, ...filters) // For each filter: for (const filter of filters) { // Set the limit count, if any. @@ -254,7 +261,7 @@ class RelaySession { } remSub (subId : string) { - this.relay.subs.delete(subId) + this.relay.subs.delete(`${this.sid}/${subId}`) this._subs.delete(subId) } diff --git a/src/routes/env.ts b/src/routes/env.ts index cdd2e64..d376827 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -283,6 +283,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } + } + // DB mode privilege gate for env writes (no legacy fallback): // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or // - allow when the authenticated DB user has role=admin. diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 732859d..b1e31b4 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -60,11 +60,13 @@ export function getValidRelays( } // Validate each relay URL and exclude localhost to avoid conflicts + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const validRelays = relayList.filter(relay => { try { const url = new URL(relay); // Exclude localhost relays to avoid conflicts with our server - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') { + // (unless explicitly allowed, e.g. for testing) + if (!allowLocalhost && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) { console.warn(`Excluding localhost relay to avoid conflicts: ${relay}`); return false; } @@ -96,7 +98,7 @@ export function getValidRelays( } // Helper functions for .env file management -const ENV_FILE_PATH = '.env'; +const ENV_FILE_PATH = process.env.ENV_FILE_PATH?.trim() || '.env'; // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..cbcc1fb --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "passed", + "failedTests": [] +} \ No newline at end of file diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs new file mode 100644 index 0000000..2c65947 --- /dev/null +++ b/tests/e2e/cosigner.mjs @@ -0,0 +1,61 @@ +/** + * Minimal FROSTR co-signer for smoke tests. + * + * Usage: node cosigner.mjs + */ + +const [,, groupCred, shareCred, relayUrl] = process.argv; + +if (!groupCred || !shareCred || !relayUrl) { + console.error('Usage: cosigner.mjs '); + process.exit(1); +} + +const { + createBifrostNode, + connectNode, +} = await import('../../node_modules/@frostr/igloo-core/dist/index.js'); + +let node; +try { + node = createBifrostNode({ + group: groupCred, + share: shareCred, + relays: [relayUrl], + }, { enableLogging: false }); + + node.on('ready', () => { + console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); + console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); + }); + node.on('closed', () => console.log('[cosigner] Node closed')); + node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); + node.on('bounced', (...args) => console.log('[cosigner] Bounced:', JSON.stringify(args).slice(0, 200))); + node.on('message', (msg) => { + console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); + }); + node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); + node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); + node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', JSON.stringify(a).slice(0, 200))); + + // Also spy on the raw WebSocket to confirm relay subscription + node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', JSON.stringify(a).slice(0, 100))); + + console.log('[cosigner] Connecting to relay:', relayUrl); + await connectNode(node); + console.log('[cosigner] Connected. Pubkey:', node.pubkey); + console.log('[cosigner] Filter:', JSON.stringify(node.client?._filter ?? node.client?.filter ?? '?')); + +} catch (err) { + console.error('[cosigner] Failed to start:', err.message ?? err); + process.exit(2); +} + +const shutdown = () => { + try { node?.close?.(); } catch {} + process.exit(0); +}; +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +setInterval(() => {}, 60_000); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..f621331 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,284 @@ +/** + * Playwright global setup for igloo-server DB-mode smoke tests. + * + * What this does: + * 1. Generate a 2-of-3 FROSTR keyset using @frostr/igloo-core (fixed nsec). + * 2. Start igloo-server on port 18002 with a fresh temp SQLite DB. + * 3. Complete DB-mode onboarding (validate-admin → setup → login). + * 4. POST /api/user/credentials (share-0 + group) to start the bifrost node. + * 5. Launch a minimal co-signer (cosigner.mjs) with share-1 via igloo-core. + * 6. Probe signing to confirm the threshold is reachable. + * 7. Create a persistent test API key. + * 8. Write all shared state to a JSON file; export SMOKE_STATE_FILE env var. + */ + +import { request } from '@playwright/test'; +import { spawn } from 'child_process'; +import type { ChildProcess } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import os from 'os'; +import type { FullConfig } from '@playwright/test'; + +// ─── Test constants ────────────────────────────────────────────────────────── + +const PORT = 18002; +const BASE_URL = `http://localhost:${PORT}`; +const TMP_DIR = path.join(os.tmpdir(), 'igloo-smoke-test'); +const STATE_FILE = path.join(TMP_DIR, 'state.json'); +const DB_PATH = path.join(TMP_DIR, 'db'); +const SERVER_LOG = path.join(TMP_DIR, 'server.log'); +const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); + +// A fixed, deterministic 32-byte secp256k1 private key (well below curve order) +const TEST_NSEC_HEX = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + +// Meets igloo-server password rules: upper + lower + digit + special(@), no sequences +const ADMIN_SECRET = 'SmokeTestAdmin1'; +const ADMIN_USERNAME = 'testadmin'; +const ADMIN_PASSWORD = 'T3stPass@9'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function pollUntil( + fn: () => Promise, + timeoutMs: number, + intervalMs = 1000, + label = 'condition', +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + if (await fn()) return; + } catch { + // ignore, keep polling + } + await sleep(intervalMs); + } + throw new Error(`Timed out waiting for: ${label}`); +} + +async function waitForHttp(url: string, timeoutMs: number): Promise { + await pollUntil( + async () => { + const res = await fetch(url).catch(() => null); + return res !== null && res.status < 500; + }, + timeoutMs, + 500, + `HTTP ${url}`, + ); +} + +function spawnDetached( + cmd: string, + args: string[], + env: NodeJS.ProcessEnv, + logFile: string, +): ChildProcess { + const out = fs.openSync(logFile, 'a'); + const proc = spawn(cmd, args, { + env: { ...process.env, ...env }, + detached: false, + stdio: ['ignore', out, out], + }); + proc.on('error', err => { + fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); + }); + return proc; +} + +// ─── Global setup ──────────────────────────────────────────────────────────── + +export default async function globalSetup(_config: FullConfig): Promise { + // Fresh temp directory every run + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + fs.mkdirSync(DB_PATH, { recursive: true }); + + // ── 1. Generate FROSTR credentials via igloo-core ────────────────────────── + console.log('[setup] Generating FROSTR credentials…'); + // Dynamic import so TS type-checker doesn't complain about the JS dist path + const { generateKeysetWithSecret, decodeGroup } = await import( + /* @ts-ignore */ + '../../node_modules/@frostr/igloo-core/dist/index.js' + ) as { + generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; + decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + }; + + // Use 2-of-2 (not 2-of-3) so signing always selects the one connected cosigner. + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + const group = decodeGroup(groupCredential); + // x-only pubkey (strip 02/03 compression prefix) for NIP-44/NIP-04 tests + const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); + + + // ── 3. Start igloo-server ────────────────────────────────────────────────── + console.log('[setup] Starting igloo-server on port', PORT, '…'); + const serverProcess = spawnDetached( + 'bun', + ['run', 'src/server.ts'], + { + ADMIN_SECRET, + DB_PATH, + HOST_PORT: String(PORT), + HOST_NAME: '127.0.0.1', + RATE_LIMIT_ENABLED: 'false', + SKIP_RELAY_PROBE: 'true', + SKIP_STARTUP_ECHO: 'true', + NODE_ENV: 'test', + AUTH_ENABLED: 'true', + FROSTR_SIGN_TIMEOUT: '15000', + UI_EVENT_LOG_INCLUDE_PINGS: 'false', + UPDATE_CHECK_DISABLED: 'true', + ALLOW_LOCALHOST_RELAY: 'true', + // Clear any .env credentials so the server starts without pre-loaded creds + GROUP_CRED: '', + SHARE_CRED: '', + RELAYS: '', + }, + SERVER_LOG, + ); + + await waitForHttp(`${BASE_URL}/api/onboarding/status`, 20_000); + console.log('[setup] Server is up.'); + + // ── 4. Onboarding ────────────────────────────────────────────────────────── + console.log('[setup] Running onboarding…'); + const api = await request.newContext({ baseURL: BASE_URL }); + + let res = await api.post('/api/onboarding/validate-admin', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + }); + if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + + res = await api.post('/api/onboarding/setup', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + + // ── 5. Login ─────────────────────────────────────────────────────────────── + console.log('[setup] Logging in…'); + res = await api.post('/api/auth/login', { + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); + const { sessionId } = (await res.json()) as { sessionId: string }; + + // ── 6. Set FROSTR credentials (share-0 + group) ──────────────────────────── + // In DB mode credentials are stored per-user (encrypted) via /api/user/credentials, + // NOT via /api/env (which only writes to .env file and does not start the node). + console.log('[setup] Setting FROSTR credentials on server…'); + res = await api.post('/api/user/credentials', { + headers: { 'X-Session-ID': sessionId }, + data: { + group_cred: groupCredential, + share_cred: shareCredentials[0], + relays: [`ws://127.0.0.1:${PORT}`], + }, + }); + if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + + // Wait for the bifrost node to go active + await pollUntil( + async () => { + const s = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); + if (!s.ok()) return false; + const body = (await s.json()) as { nodeActive: boolean }; + return body.nodeActive === true; + }, + 15_000, + 1000, + 'nodeActive = true', + ); + console.log('[setup] Node is active.'); + + // ── 7. Start co-signer (share index 1) using igloo-core directly ────────── + // We use a minimal cosigner.mjs script so we control which share credentials + // are used and avoid igloo-cli's interactive TUI entirely. + // Server holds shareCredentials[0]; co-signer holds shareCredentials[1]. + console.log('[setup] Starting co-signer with shareCredentials[1]…'); + const cosignerProcess = spawnDetached( + 'node', + [ + path.resolve('tests/e2e/cosigner.mjs'), + groupCredential, + shareCredentials[1], + `ws://127.0.0.1:${PORT}`, + ], + {}, + COSIGNER_LOG, + ); + console.log('[setup] Co-signer pid:', cosignerProcess.pid); + + // ── 8. Signing readiness probe ───────────────────────────────────────────── + // Retry signing a known 32-byte hex to confirm threshold is reachable + console.log('[setup] Probing signing (waiting for co-signer to join relay)…'); + const TEST_MSG = 'a'.repeat(64); // 32-byte all-0xAA event id + let signOk = false; + for (let attempt = 1; attempt <= 5; attempt++) { + await sleep(3000); + const sr = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: TEST_MSG }, + }).catch(() => null); + if (sr && sr.ok()) { + signOk = true; + console.log(`[setup] Signing OK on attempt ${attempt}.`); + break; + } + const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; + console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); + } + if (!signOk) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error(`Co-signer did not become ready within 25 s.\nCo-signer log:\n${cosLog}`); + } + + // ── 9. Create persistent test API key ───────────────────────────────────── + console.log('[setup] Creating test API key…'); + res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'smoke-test-key' }, + }); + let apiKey: string | null = null; + let apiKeyId: string | null = null; + if (res.ok()) { + const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; + apiKey = body.apiKey.token; + apiKeyId = String(body.apiKey.id); + } else { + console.warn('[setup] Could not create API key – admin key tests will be skipped.'); + } + + await api.dispose(); + + // ── 10. Persist shared state ─────────────────────────────────────────────── + const state = { + port: PORT, + baseUrl: BASE_URL, + tmpDir: TMP_DIR, + serverPid: serverProcess.pid, + cosignerPid: cosignerProcess.pid, + sessionId, + apiKey, + apiKeyId, + groupCredential, + shareCredentials, + groupPubkeyHex, + adminUsername: ADMIN_USERNAME, + adminPassword: ADMIN_PASSWORD, + adminSecret: ADMIN_SECRET, + }; + + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + process.env.SMOKE_STATE_FILE = STATE_FILE; + + console.log('[setup] ✓ Global setup complete. State saved to', STATE_FILE); +} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts new file mode 100644 index 0000000..f4ffd72 --- /dev/null +++ b/tests/e2e/global-teardown.ts @@ -0,0 +1,48 @@ +/** + * Playwright global teardown – kills the server + co-signer and cleans up + * the temp directory created by global-setup. + */ + +import fs from 'fs'; +import type { FullConfig } from '@playwright/test'; + +export default async function globalTeardown(_config: FullConfig): Promise { + const stateFile = process.env.SMOKE_STATE_FILE; + if (!stateFile || !fs.existsSync(stateFile)) { + console.warn('[teardown] No state file found – nothing to clean up.'); + return; + } + + let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; + try { + state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + } catch { + console.warn('[teardown] Could not parse state file.'); + return; + } + + for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { + if (!pid) continue; + try { + process.kill(pid, 'SIGTERM'); + console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); + } catch (err: unknown) { + // ESRCH = process already gone, which is fine + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { + console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); + } + } + } + + // Brief pause to let processes flush logs + await new Promise(r => setTimeout(r, 500)); + + if (state.tmpDir) { + try { + fs.rmSync(state.tmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', state.tmpDir); + } catch (err) { + console.warn('[teardown] Could not remove temp dir:', err); + } + } +} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts new file mode 100644 index 0000000..91fba5c --- /dev/null +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -0,0 +1,117 @@ +/** + * Auth smoke tests – login, logout, session auth, API-key auth, 401 enforcement. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; + +test.describe('Auth – /api/auth', () => { + test('GET /api/auth/status returns enabled methods', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/auth/status'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + await api.dispose(); + }); + + test('POST /api/auth/login – valid credentials return sessionId', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('sessionId'); + expect(typeof body.sessionId).toBe('string'); + expect(body.sessionId.length).toBeGreaterThan(8); + await api.dispose(); + }); + + test('POST /api/auth/login – wrong password returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('POST /api/auth/login – unknown user returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/auth/login', { + data: { username: 'nobody', password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/peers – no auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/status – valid session returns 200', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status – valid API key (X-API-Key) returns 200', async () => { + test.skip(!apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-API-Key': apiKey! }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status – valid API key (Bearer) returns 200', async () => { + test.skip(!apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { Authorization: `Bearer ${apiKey!}` }, + }); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/peers – invalid API key returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers', { + headers: { 'X-API-Key': 'totally-invalid-key' }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('POST /api/auth/logout – returns 200 and clears session', async () => { + // Log in fresh so we don't burn the shared session + const api = await request.newContext({ baseURL: baseUrl }); + const loginRes = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + const { sessionId: tempSession } = await loginRes.json(); + + const logoutRes = await api.post('/api/auth/logout', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(logoutRes.status()).toBe(200); + + // The session should now be invalid + const afterRes = await api.get('/api/peers', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(afterRes.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts new file mode 100644 index 0000000..2190c8c --- /dev/null +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -0,0 +1,98 @@ +/** + * Status and peers smoke tests. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Status – /api/status', () => { + test('GET /api/status is publicly accessible without auth', async () => { + // /api/status intentionally allows unauthenticated health checks + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status'); + expect(res.status()).toBe(200); + await api.dispose(); + }); + + test('GET /api/status returns 200 with node info', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body.serverRunning).toBe(true); + expect(body.nodeActive).toBe(true); + expect(body).toHaveProperty('health'); + expect(body).toHaveProperty('relayCount'); + expect(body).toHaveProperty('timestamp'); + await api.dispose(); + }); + + test('GET /api/status has valid health object', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + const body = await res.json(); + expect(body.health).toHaveProperty('isConnected'); + expect(typeof body.health.isConnected).toBe('boolean'); + expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); + await api.dispose(); + }); +}); + +test.describe('Peers – /api/peers', () => { + test('GET /api/peers returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/peers returns peer list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('peers'); + expect(Array.isArray(body.peers)).toBe(true); + // 2-of-3 keyset: 2 remote peers (self filtered out) + expect(body.peers.length).toBeGreaterThanOrEqual(1); + expect(typeof body.total).toBe('number'); + expect(typeof body.online).toBe('number'); + await api.dispose(); + }); + + test('GET /api/peers/group returns group pubkey', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers/group', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(typeof body.threshold).toBe('number'); + await api.dispose(); + }); + + test('GET /api/peers/self returns own share pubkey', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/peers/self', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(typeof body.pubkey).toBe('string'); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts new file mode 100644 index 0000000..cd118f4 --- /dev/null +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -0,0 +1,140 @@ +/** + * NIP-44 and NIP-04 encrypt/decrypt smoke tests. + * + * We use the group pubkey as the "peer" for ECDH operations – the server + * holds share-0 so it can derive the shared secret with any co-participant. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, groupPubkeyHex } = state; + +const PLAINTEXT = 'Hello from igloo smoke test!'; + +// ─── NIP-44 ────────────────────────────────────────────────────────────────── + +test.describe('NIP-44 – /api/nip44', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('encrypt returns ciphertext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + expect(typeof body.result).toBe('string'); + expect(body.result.length).toBeGreaterThan(0); + await api.dispose(); + }); + + test('encrypt then decrypt round-trips plaintext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + const encRes = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip44/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); + + await api.dispose(); + }); + + test('invalid peer_pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('missing content returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); + +// ─── NIP-04 ────────────────────────────────────────────────────────────────── + +test.describe('NIP-04 – /api/nip04', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('encrypt returns ciphertext with IV suffix', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + // NIP-04 ciphertext has the form ?iv= + expect(body.result).toMatch(/\?iv=/); + await api.dispose(); + }); + + test('encrypt then decrypt round-trips plaintext', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + const encRes = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip04/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); + + await api.dispose(); + }); + + test('invalid peer_pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts new file mode 100644 index 0000000..8e8d943 --- /dev/null +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -0,0 +1,130 @@ +/** + * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. + * + * sign timeout: 15 s (FROSTR_SIGN_TIMEOUT env set in global-setup). + * Test timeout overridden to 30 s to accommodate the signing round-trip. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, groupPubkeyHex } = state; + +// Valid 32-byte hex event IDs for signing +const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +test.describe('Sign – /api/sign', () => { + test.setTimeout(30_000); + + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('returns 400 for invalid (non-hex) message', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'not-hex' }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('returns 400 for message shorter than 32 bytes', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'deadbeef' }, // only 4 bytes + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('returns 400 for missing body', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: {}, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('signs a 32-byte hex message and returns signature', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id', EVENT_ID_A); + expect(body).toHaveProperty('signature'); + expect(typeof body.signature).toBe('string'); + // Schnorr signature = 64 bytes = 128 hex chars + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('signs a full event object and returns signature', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + // Use the group pubkey as the event author pubkey + const event = { + pubkey: groupPubkeyHex, + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'igloo smoke test', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { event }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('signature'); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('signing works with API key auth', async () => { + test.skip(!state.apiKey, 'No API key available'); + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-API-Key': state.apiKey! }, + data: { message: EVENT_ID_B }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + await api.dispose(); + }); + + test('event with invalid pubkey returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { + event: { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }, + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts new file mode 100644 index 0000000..470be6e --- /dev/null +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -0,0 +1,138 @@ +/** + * Admin endpoint smoke tests: + * - API key creation, listing, revocation + * - User listing, whoami + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId, adminUsername } = state; + +test.describe('Admin – API keys', () => { + test('GET /api/admin/api-keys returns list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('apiKeys'); + expect(Array.isArray(body.apiKeys)).toBe(true); + // At minimum the key created in global setup should be here + expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); + await api.dispose(); + }); + + test('POST /api/admin/api-keys creates a new key', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'temp-test-key' }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); + await api.dispose(); + }); + + test('new API key can authenticate', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'auth-test-key' }, + }); + const { apiKey } = await createRes.json(); + + // Use key to hit a protected route + const authRes = await api.get('/api/status', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); + + await api.dispose(); + }); + + test('revoked API key returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(beforeRes.status()).toBe(200); + + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); + + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(afterRes.status()).toBe(401); + + await api.dispose(); + }); + + test('GET /api/admin/api-keys without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/api-keys'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); + +test.describe('Admin – Users', () => { + test('GET /api/admin/users returns user list', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/users', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('users'); + expect(Array.isArray(body.users)).toBe(true); + expect(body.users.length).toBeGreaterThanOrEqual(1); + // Our admin user must be in the list + const found = body.users.some((u: { username: string }) => u.username === adminUsername); + expect(found).toBe(true); + await api.dispose(); + }); + + test('GET /api/admin/whoami returns admin identity', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/whoami', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('userId'); + await api.dispose(); + }); + + test('GET /api/admin/users without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/admin/users'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts new file mode 100644 index 0000000..4c85213 --- /dev/null +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -0,0 +1,86 @@ +/** + * UI event-log smoke tests. + * Signing operations performed in 04-sign.spec.ts will have produced log entries. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Event log – /api/event-log', () => { + test('returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/event-log returns entries array', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('entries'); + expect(Array.isArray(body.entries)).toBe(true); + await api.dispose(); + }); + + test('entries have expected shape', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + const body = await res.json(); + + // If there are entries from sign tests, validate their shape + if (body.entries.length > 0) { + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); + } + await api.dispose(); + }); + + test('pagination params are accepted', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log?limit=5', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.entries.length).toBeLessThanOrEqual(5); + await api.dispose(); + }); + + test('GET /api/event-log/export returns NDJSON', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log/export', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + + // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array + const contentType = res.headers()['content-type'] ?? ''; + expect(contentType).toContain('ndjson'); + + const text = await res.text(); + // Each non-empty line must be valid JSON + const lines = text.trim().split('\n').filter(Boolean); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + await api.dispose(); + }); + + test('export without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/event-log/export'); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts new file mode 100644 index 0000000..0ced112 --- /dev/null +++ b/tests/e2e/specs/07-env.e2e.ts @@ -0,0 +1,85 @@ +/** + * Credential management smoke tests – /api/env. + * + * GET returns the current credential state. + * POST with invalid credentials returns 400. + * We do NOT update valid credentials here to avoid disrupting co-signer timing. + */ + +import { test, expect, request } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, sessionId } = state; + +test.describe('Env / credentials – /api/env', () => { + test('GET /api/env returns 401 without auth', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/env'); + expect(res.status()).toBe(401); + await api.dispose(); + }); + + test('GET /api/env with session returns credential metadata', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.get('/api/env', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + // In DB mode the response should indicate that credentials are present + expect(body).toHaveProperty('hasCredentials', true); + await api.dispose(); + }); + + test('POST /api/env – invalid GROUP_CRED returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: 'not-a-valid-bfgroup-credential', + SHARE_CRED: state.shareCredentials[0], + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env – invalid SHARE_CRED returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: 'not-a-valid-bfshare-credential', + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env – invalid relay URL returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: ['not-a-websocket-url'], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + + test('POST /api/env without auth returns 401', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + }); + expect(res.status()).toBe(401); + await api.dispose(); + }); +}); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts new file mode 100644 index 0000000..8c38bc5 --- /dev/null +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -0,0 +1,124 @@ +/** + * Browser UI smoke tests – uses Playwright's full browser to exercise the SPA. + * + * Prerequisites: `bun run build` must have been run so static/app.js exists. + * The server is already running (started in global-setup). + */ + +import { test, expect } from '@playwright/test'; +import { loadState } from '../state.js'; + +const state = loadState(); +const { baseUrl, adminUsername, adminPassword } = state; + +test.describe('UI – Login page', () => { + test('/ renders the login form when not authenticated', async ({ page }) => { + await page.goto(baseUrl); + // The SPA should show either the login form or onboarding + // Since onboarding is complete, we expect the login form + await expect(page).toHaveURL(baseUrl + '/'); + // Login form has username + password inputs + await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ + timeout: 10_000, + }); + await expect(page.locator('input[type="password"]').first()).toBeVisible(); + }); + + test('login form accepts credentials and navigates to dashboard', async ({ page }) => { + await page.goto(baseUrl); + + // Fill in the login form + const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + + await usernameField.fill(adminUsername); + await passwordField.fill(adminPassword); + + // Submit (button with type=submit or labeled "Login"/"Sign in") + const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); + await submitBtn.click(); + + // After login we should see the main app tabs + await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ + timeout: 10_000, + }); + }); +}); + +test.describe('UI – Authenticated app', () => { + // Log in once per test block using page fixtures (each test gets a fresh page) + test.beforeEach(async ({ page }) => { + await page.goto(baseUrl); + + const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); + const passwordField = page.locator('input[type="password"]').first(); + await usernameField.fill(adminUsername); + await passwordField.fill(adminPassword); + const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); + await submitBtn.click(); + + // Wait for the app to load + await page.waitForLoadState('networkidle'); + }); + + test('Signer tab is visible and shows node status indicator', async ({ page }) => { + // The Signer tab or its content should be visible after login + const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); + await expect(signerTab).toBeVisible({ timeout: 8_000 }); + }); + + test('Configure tab is accessible', async ({ page }) => { + const configureTab = page + .locator('[role="tab"]:has-text("Configure"), button:has-text("Configure"), a:has-text("Configure")') + .first(); + await expect(configureTab).toBeVisible({ timeout: 8_000 }); + await configureTab.click(); + // After clicking, the configure panel content should appear + await page.waitForLoadState('networkidle'); + // Look for credential-related inputs or headings + const configContent = page.locator('input, textarea, [data-testid*="cred"]').first(); + await expect(configContent).toBeVisible({ timeout: 8_000 }); + }); + + test('API Keys tab is accessible', async ({ page }) => { + const apiKeysTab = page + .locator('[role="tab"]:has-text("API Keys"), [role="tab"]:has-text("Api Keys"), button:has-text("API Keys")') + .first(); + await expect(apiKeysTab).toBeVisible({ timeout: 8_000 }); + await apiKeysTab.click(); + await page.waitForLoadState('networkidle'); + // The tab panel should render without error + await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); + }); + + test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { + // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). + // It renders a div with role="button" and a span containing "Event Log". + const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); + await expect(eventLogToggle).toBeVisible({ timeout: 8_000 }); + await eventLogToggle.click(); + await page.waitForLoadState('networkidle'); + await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); + }); + + test('Logout button signs out and returns to login', async ({ page }) => { + // Find and click a logout button + const logoutBtn = page + .locator('button:has-text("Logout"), button:has-text("Sign out"), a:has-text("Logout"), [aria-label*="logout" i]') + .first(); + await expect(logoutBtn).toBeVisible({ timeout: 8_000 }); + await logoutBtn.click(); + await page.waitForLoadState('networkidle'); + + // Should be back at the login form + await expect(page.locator('input[type="password"]').first()).toBeVisible({ timeout: 8_000 }); + }); +}); + +test.describe('UI – Onboarding already completed', () => { + test('/ does not show onboarding when DB is initialised', async ({ page }) => { + await page.goto(baseUrl); + // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear + await expect(page.locator('body')).not.toContainText('Admin Secret', { timeout: 6_000 }); + }); +}); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts new file mode 100644 index 0000000..76616f5 --- /dev/null +++ b/tests/e2e/state.ts @@ -0,0 +1,51 @@ +import fs from 'fs'; + +export interface SmokeTestState { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; + apiKey: string | null; + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; + groupPubkeyHex: string; // x-only (no 02/03 prefix), used for NIP-44/NIP-04 + adminUsername: string; + adminPassword: string; + adminSecret: string; +} + +const STUB: SmokeTestState = { + port: 18002, + baseUrl: 'http://localhost:18002', + tmpDir: '', + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: '', + adminPassword: '', + adminSecret: '', +}; + +/** + * Load shared test state written by global-setup. + * During test discovery (--list) or if SMOKE_STATE_FILE is not yet set, returns + * a harmless stub so that module-level const initialisations succeed. + * The real values are always present when tests actually execute. + */ +export function loadState(): SmokeTestState { + const stateFile = process.env.SMOKE_STATE_FILE; + if (!stateFile) return STUB; + try { + return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SmokeTestState; + } catch { + return STUB; + } +} diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 7eea3ae..87db273 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -5,16 +5,68 @@ import { pathToFileURL } from 'url'; export const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; +const ISOLATED_ENV_KEYS = [ + 'NODE_ENV', + 'HEADLESS', + 'AUTH_ENABLED', + 'API_KEY', + 'BASIC_AUTH_USER', + 'BASIC_AUTH_PASS', + 'GROUP_CRED', + 'SHARE_CRED', + 'GROUP_NAME', + 'RELAYS', + 'PEER_POLICIES', + 'DB_PATH', + 'ADMIN_SECRET', + 'SESSION_SECRET', + 'ALLOWED_ORIGINS', + 'TRUST_PROXY', + 'AUTO_ADMIN_SECRET', + 'SKIP_ADMIN_SECRET_VALIDATION', + 'ENV_FILE_PATH', +]; + +const ISOLATED_ENV_PREFIXES = [ + 'RATE_LIMIT_', +]; + +function buildScriptEnv(overrides: Record): Record { + const nextEnv: Record = { ...process.env } as Record; + + for (const key of ISOLATED_ENV_KEYS) { + delete nextEnv[key]; + } + + for (const key of Object.keys(nextEnv)) { + if (ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) { + delete nextEnv[key]; + } + } + + return { + ...nextEnv, + NODE_ENV: 'test', + ...overrides, + }; +} + export function runRouteScript(code: string, env: Record = {}) { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); writeFileSync(runner, code, 'utf8'); + const isolatedEnv = buildScriptEnv({ + ENV_FILE_PATH: path.join(tmpDir, '.env'), + DB_PATH: path.join(tmpDir, 'igloo.db'), + ...env + }); + const result = Bun.spawnSync({ - cmd: ['bun', 'run', runner], + cmd: ['bun', '--no-env-file', 'run', runner], cwd: process.cwd(), - env: { ...process.env, ...env }, + env: isolatedEnv, stdout: 'pipe', stderr: 'pipe', timeout: 15000, From ac2649647df47ebff12652f667fc093f646bfb70 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 20:09:01 -0600 Subject: [PATCH 24/69] fix test placeholder strings --- tests/routes/env.db-mode.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 2b331fd..91d1c2c 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -77,11 +77,15 @@ describe('DB-mode /api/env behavior', () => { updateNode: () => {} }; + // Generate real FROSTR credentials so validateGroup/validateShare pass + const { generateKeysetWithSecret } = await import(root + 'node_modules/@frostr/igloo-core/dist/index.js'); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const headers = new Headers({ 'Content-Type': 'application/json', 'X-Admin-Secret': 'test-admin-secret' }); - const body = { GROUP_CRED: 'group-cred-stub', SHARE_CRED: 'share-cred-stub' }; + const body = { GROUP_CRED: groupCredential, SHARE_CRED: shareCredentials[0] }; const req = new Request('http://localhost/api/env', { method: 'POST', headers, body: JSON.stringify(body) }); const res = await handleEnvRoute(req, new URL(req.url), context, { authenticated: true, userId: 2 }); From c579a3ef4cd518966482d5ac0b07e7e3b977ed31 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 20:39:34 -0600 Subject: [PATCH 25/69] test: add Playwright E2E smoke suite and patch dev dep vulnerabilities --- .github/workflows/ci.yml | 4 +- bun.lock | 103 ++++--- package.json | 13 +- tests/e2e/global-setup.ts | 462 +++++++++++++++++++------------- tests/e2e/global-teardown.ts | 37 ++- tests/e2e/specs/01-auth.e2e.ts | 8 +- tests/e2e/specs/05-admin.e2e.ts | 4 +- 7 files changed, 370 insertions(+), 261 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1245aae..d4bfef2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,8 +25,8 @@ jobs: - name: Type check run: bun run tsc --noEmit - - name: Run backend tests - run: bun run test:unit + - name: Run route tests + run: bun test tests/routes - name: Build frontend run: bun run build diff --git a/bun.lock b/bun.lock index 079d0ee..c9f0e76 100644 --- a/bun.lock +++ b/bun.lock @@ -32,9 +32,10 @@ "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", @@ -42,6 +43,14 @@ }, }, }, + "overrides": { + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6", + "glob": "^10.5.0", + "js-yaml": "^4.1.1", + "minimatch": "^10.2.1", + "undici": "^6.23.0", + }, "packages": { "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -65,55 +74,57 @@ "@emotion/unitless": ["@emotion/unitless@0.8.1", "", {}, "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], "@exodus/schemasafe": ["@exodus/schemasafe@1.3.0", "", {}, "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw=="], @@ -283,7 +294,7 @@ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -299,13 +310,13 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="], "better-ajv-errors": ["better-ajv-errors@1.2.0", "", { "dependencies": { "@babel/code-frame": "^7.16.0", "@humanwhocodes/momoa": "^2.0.2", "chalk": "^4.1.2", "jsonpointer": "^5.0.0", "leven": "^3.1.0 < 4" }, "peerDependencies": { "ajv": "4.11.8 - 8" } }, "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], - "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + "brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -345,8 +356,6 @@ "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "concat-stream": ["concat-stream@2.0.0", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.0.2", "typedarray": "^0.0.6" } }, "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A=="], "concurrently": ["concurrently@9.2.1", "", { "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", "shell-quote": "1.8.3", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng=="], @@ -403,7 +412,7 @@ "es6-promise": ["es6-promise@3.3.1", "", {}, "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg=="], - "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -419,7 +428,7 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], - "fast-xml-parser": ["fast-xml-parser@4.5.3", "", { "dependencies": { "strnum": "^1.1.1" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig=="], + "fast-xml-parser": ["fast-xml-parser@5.3.7", "", { "dependencies": { "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA=="], "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], @@ -431,8 +440,6 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -445,7 +452,7 @@ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -465,8 +472,6 @@ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], - "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], @@ -503,7 +508,7 @@ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsep": ["jsep@1.4.0", "", {}, "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw=="], @@ -545,7 +550,7 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -591,8 +596,6 @@ "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "openapi-sampler": ["openapi-sampler@1.6.2", "", { "dependencies": { "@types/json-schema": "^7.0.7", "fast-xml-parser": "^4.5.0", "json-pointer": "0.6.2" } }, "sha512-NyKGiFKfSWAZr4srD/5WDhInOWDhfml32h/FKUqLpEwKJt0kG0LGUU0MdyNkKrVGuJnw6DuPWq/sHCwAMpiRxg=="], @@ -603,8 +606,6 @@ "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], - "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], @@ -741,7 +742,7 @@ "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "strnum": ["strnum@2.1.2", "", {}, "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ=="], "styled-components": ["styled-components@6.1.19", "", { "dependencies": { "@emotion/is-prop-valid": "1.2.2", "@emotion/unitless": "0.8.1", "@types/stylis": "4.2.5", "css-to-react-native": "3.2.0", "csstype": "3.1.3", "postcss": "8.4.49", "shallowequal": "1.1.0", "stylis": "4.3.2", "tslib": "2.6.2" }, "peerDependencies": { "react": ">= 16.8.0", "react-dom": ">= 16.8.0" } }, "sha512-1v/e3Dl1BknC37cXMhwGomhO8AkYmN41CqyX9xhUDxry1ns3BFQy2lLDRQXJRdVVWB9OHemv/53xaStimvWyuA=="], @@ -781,7 +782,7 @@ "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], - "undici": ["undici@6.22.0", "", {}, "sha512-hU/10obOIu62MGYjdskASR3CUAiYaFTtC9Pa6vHyf//mAipSvSQg6od2CnJswq7fvzNS3zJhxoRkgNVaHurWKw=="], + "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -805,8 +806,6 @@ "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], @@ -853,8 +852,6 @@ "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], - "glob/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], - "nostr-tools/@noble/ciphers": ["@noble/ciphers@0.5.3", "", {}, "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="], "nostr-tools/@noble/curves": ["@noble/curves@1.2.0", "", { "dependencies": { "@noble/hashes": "1.3.2" } }, "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw=="], @@ -879,8 +876,6 @@ "styled-components/postcss": ["postcss@8.4.49", "", { "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA=="], - "sucrase/glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], - "swagger2openapi/yaml": ["yaml@1.10.2", "", {}, "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="], "swagger2openapi/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], @@ -897,16 +892,12 @@ "concurrently/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "glob/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], - "nostr-tools/@noble/curves/@noble/hashes": ["@noble/hashes@1.3.2", "", {}, "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ=="], "oas-resolver/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "oas-resolver/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - "sucrase/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], - "swagger2openapi/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], "swagger2openapi/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], diff --git a/package.json b/package.json index 822551e..02cdb89 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,9 @@ "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", "test:unit": "bun test --max-concurrency=1 src tests/routes", + "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", "test:e2e": "npx playwright test", + "test:e2e:nightly": "npx playwright test", "test:e2e:ui": "npx playwright test --project=ui", "test:e2e:api": "npx playwright test --project=api", "test:e2e:report": "npx playwright show-report", @@ -69,12 +71,21 @@ "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", "@types/yaml": "^1.9.7", + "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", - "esbuild": "^0.24.2", + "esbuild": "^0.25.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.18", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.3" + }, + "overrides": { + "glob": "^10.5.0", + "minimatch": "^10.2.1", + "js-yaml": "^4.1.1", + "undici": "^6.23.0", + "ajv": "^8.18.0", + "fast-xml-parser": "^5.3.6" } } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index f621331..bbc1534 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -1,30 +1,31 @@ /** - * Playwright global setup for igloo-server DB-mode smoke tests. + * Playwright global setup for DB-mode smoke tests. * * What this does: - * 1. Generate a 2-of-3 FROSTR keyset using @frostr/igloo-core (fixed nsec). - * 2. Start igloo-server on port 18002 with a fresh temp SQLite DB. - * 3. Complete DB-mode onboarding (validate-admin → setup → login). - * 4. POST /api/user/credentials (share-0 + group) to start the bifrost node. - * 5. Launch a minimal co-signer (cosigner.mjs) with share-1 via igloo-core. - * 6. Probe signing to confirm the threshold is reachable. - * 7. Create a persistent test API key. - * 8. Write all shared state to a JSON file; export SMOKE_STATE_FILE env var. + * 1. Generate a deterministic 2-of-2 FROSTR keyset. + * 2. Start igloo-server against a fresh temporary DB path. + * 3. Complete onboarding and login. + * 4. Persist user credentials to start the Bifrost node. + * 5. Start a real co-signer process. + * 6. Probe signing readiness. + * 7. Create a reusable API key for auth tests. + * 8. Persist shared state for specs and teardown. */ import { request } from '@playwright/test'; +import type { APIRequestContext, FullConfig } from '@playwright/test'; import { spawn } from 'child_process'; import type { ChildProcess } from 'child_process'; -import path from 'path'; import fs from 'fs'; +import net from 'net'; import os from 'os'; -import type { FullConfig } from '@playwright/test'; - -// ─── Test constants ────────────────────────────────────────────────────────── +import path from 'path'; -const PORT = 18002; -const BASE_URL = `http://localhost:${PORT}`; -const TMP_DIR = path.join(os.tmpdir(), 'igloo-smoke-test'); +const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; +const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); +const DEFAULT_PORT = Number.isFinite(REQUESTED_PORT) && REQUESTED_PORT > 0 ? REQUESTED_PORT : 18002; +const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +const TMP_DIR = process.env.SMOKE_TEST_TMP_DIR ?? path.join(os.tmpdir(), `igloo-smoke-test-${RUN_ID}`); const STATE_FILE = path.join(TMP_DIR, 'state.json'); const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); @@ -38,12 +39,75 @@ const ADMIN_SECRET = 'SmokeTestAdmin1'; const ADMIN_USERNAME = 'testadmin'; const ADMIN_PASSWORD = 'T3stPass@9'; -// ─── Helpers ───────────────────────────────────────────────────────────────── +type SetupState = { + port: number; + baseUrl: string; + tmpDir: string; + serverPid: number; + cosignerPid: number; + sessionId: string; + apiKey: string | null; + apiKeyId: string | null; + groupCredential: string; + shareCredentials: string[]; + groupPubkeyHex: string; + adminUsername: string; + adminPassword: string; + adminSecret: string; +}; function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } +function writeState(state: SetupState) { + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + process.env.SMOKE_STATE_FILE = STATE_FILE; +} + +function terminateProcess(proc: ChildProcess | null, label: string) { + if (!proc?.pid) return; + try { + process.kill(proc.pid, 'SIGTERM'); + console.log(`[setup] Sent SIGTERM to ${label} (pid ${proc.pid})`); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { + console.warn(`[setup] Could not stop ${label} (pid ${proc.pid})`, err); + } + } +} + +function canBindPort(port: number, host: string): Promise { + return new Promise(resolve => { + const srv = net.createServer(); + srv.once('error', () => resolve(false)); + srv.listen(port, host, () => { + srv.close(() => resolve(true)); + }); + }); +} + +function reserveRandomPort(host: string): Promise { + return new Promise((resolve, reject) => { + const srv = net.createServer(); + srv.once('error', reject); + srv.listen(0, host, () => { + const addr = srv.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + srv.close(() => resolve(port)); + }); + }); +} + +async function resolvePort(host: string, preferredPort: number): Promise { + if (await canBindPort(preferredPort, host)) { + return preferredPort; + } + const fallbackPort = await reserveRandomPort(host); + console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort}`); + return fallbackPort; +} + async function pollUntil( fn: () => Promise, timeoutMs: number, @@ -55,7 +119,7 @@ async function pollUntil( try { if (await fn()) return; } catch { - // ignore, keep polling + // ignore and keep polling } await sleep(intervalMs); } @@ -92,193 +156,209 @@ function spawnDetached( return proc; } -// ─── Global setup ──────────────────────────────────────────────────────────── - export default async function globalSetup(_config: FullConfig): Promise { - // Fresh temp directory every run - if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); - fs.mkdirSync(TMP_DIR, { recursive: true }); - fs.mkdirSync(DB_PATH, { recursive: true }); - - // ── 1. Generate FROSTR credentials via igloo-core ────────────────────────── - console.log('[setup] Generating FROSTR credentials…'); - // Dynamic import so TS type-checker doesn't complain about the JS dist path - const { generateKeysetWithSecret, decodeGroup } = await import( - /* @ts-ignore */ - '../../node_modules/@frostr/igloo-core/dist/index.js' - ) as { - generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; - decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + const host = '127.0.0.1'; + const port = await resolvePort(host, DEFAULT_PORT); + const baseUrl = `http://${host}:${port}`; + + const state: SetupState = { + port, + baseUrl, + tmpDir: TMP_DIR, + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: ADMIN_USERNAME, + adminPassword: ADMIN_PASSWORD, + adminSecret: ADMIN_SECRET, }; - // Use 2-of-2 (not 2-of-3) so signing always selects the one connected cosigner. - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); - const group = decodeGroup(groupCredential); - // x-only pubkey (strip 02/03 compression prefix) for NIP-44/NIP-04 tests - const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); - - - // ── 3. Start igloo-server ────────────────────────────────────────────────── - console.log('[setup] Starting igloo-server on port', PORT, '…'); - const serverProcess = spawnDetached( - 'bun', - ['run', 'src/server.ts'], - { - ADMIN_SECRET, - DB_PATH, - HOST_PORT: String(PORT), - HOST_NAME: '127.0.0.1', - RATE_LIMIT_ENABLED: 'false', - SKIP_RELAY_PROBE: 'true', - SKIP_STARTUP_ECHO: 'true', - NODE_ENV: 'test', - AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '15000', - UI_EVENT_LOG_INCLUDE_PINGS: 'false', - UPDATE_CHECK_DISABLED: 'true', - ALLOW_LOCALHOST_RELAY: 'true', - // Clear any .env credentials so the server starts without pre-loaded creds - GROUP_CRED: '', - SHARE_CRED: '', - RELAYS: '', - }, - SERVER_LOG, - ); + let api: APIRequestContext | null = null; + let serverProcess: ChildProcess | null = null; + let cosignerProcess: ChildProcess | null = null; - await waitForHttp(`${BASE_URL}/api/onboarding/status`, 20_000); - console.log('[setup] Server is up.'); + process.env.SMOKE_STATE_FILE = STATE_FILE; - // ── 4. Onboarding ────────────────────────────────────────────────────────── - console.log('[setup] Running onboarding…'); - const api = await request.newContext({ baseURL: BASE_URL }); + try { + if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + fs.mkdirSync(TMP_DIR, { recursive: true }); + fs.mkdirSync(DB_PATH, { recursive: true }); + writeState(state); - let res = await api.post('/api/onboarding/validate-admin', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - }); - if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + console.log('[setup] Generating FROSTR credentials...'); + const { generateKeysetWithSecret, decodeGroup } = await import( + // @ts-ignore + '../../node_modules/@frostr/igloo-core/dist/index.js' + ) as { + generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; + decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; + }; - res = await api.post('/api/onboarding/setup', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + const group = decodeGroup(groupCredential); + const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); + state.groupCredential = groupCredential; + state.shareCredentials = shareCredentials; + state.groupPubkeyHex = groupPubkeyHex; + writeState(state); - // ── 5. Login ─────────────────────────────────────────────────────────────── - console.log('[setup] Logging in…'); - res = await api.post('/api/auth/login', { - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); - const { sessionId } = (await res.json()) as { sessionId: string }; - - // ── 6. Set FROSTR credentials (share-0 + group) ──────────────────────────── - // In DB mode credentials are stored per-user (encrypted) via /api/user/credentials, - // NOT via /api/env (which only writes to .env file and does not start the node). - console.log('[setup] Setting FROSTR credentials on server…'); - res = await api.post('/api/user/credentials', { - headers: { 'X-Session-ID': sessionId }, - data: { - group_cred: groupCredential, - share_cred: shareCredentials[0], - relays: [`ws://127.0.0.1:${PORT}`], - }, - }); - if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + console.log('[setup] Starting igloo-server on port', port, '...'); + serverProcess = spawnDetached( + 'bun', + ['run', 'src/server.ts'], + { + ADMIN_SECRET, + DB_PATH, + HOST_PORT: String(port), + HOST_NAME: host, + RATE_LIMIT_ENABLED: 'false', + SKIP_RELAY_PROBE: 'true', + SKIP_STARTUP_ECHO: 'true', + NODE_ENV: 'test', + AUTH_ENABLED: 'true', + FROSTR_SIGN_TIMEOUT: '15000', + UI_EVENT_LOG_INCLUDE_PINGS: 'false', + UPDATE_CHECK_DISABLED: 'true', + ALLOW_LOCALHOST_RELAY: 'true', + // Clear any .env credentials so the server starts without pre-loaded creds + GROUP_CRED: '', + SHARE_CRED: '', + RELAYS: '', + }, + SERVER_LOG, + ); + state.serverPid = serverProcess.pid ?? 0; + writeState(state); - // Wait for the bifrost node to go active - await pollUntil( - async () => { - const s = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); - if (!s.ok()) return false; - const body = (await s.json()) as { nodeActive: boolean }; - return body.nodeActive === true; - }, - 15_000, - 1000, - 'nodeActive = true', - ); - console.log('[setup] Node is active.'); - - // ── 7. Start co-signer (share index 1) using igloo-core directly ────────── - // We use a minimal cosigner.mjs script so we control which share credentials - // are used and avoid igloo-cli's interactive TUI entirely. - // Server holds shareCredentials[0]; co-signer holds shareCredentials[1]. - console.log('[setup] Starting co-signer with shareCredentials[1]…'); - const cosignerProcess = spawnDetached( - 'node', - [ - path.resolve('tests/e2e/cosigner.mjs'), - groupCredential, - shareCredentials[1], - `ws://127.0.0.1:${PORT}`, - ], - {}, - COSIGNER_LOG, - ); - console.log('[setup] Co-signer pid:', cosignerProcess.pid); - - // ── 8. Signing readiness probe ───────────────────────────────────────────── - // Retry signing a known 32-byte hex to confirm threshold is reachable - console.log('[setup] Probing signing (waiting for co-signer to join relay)…'); - const TEST_MSG = 'a'.repeat(64); // 32-byte all-0xAA event id - let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { - await sleep(3000); - const sr = await api.post('/api/sign', { + await waitForHttp(`${baseUrl}/api/onboarding/status`, 20_000); + console.log('[setup] Server is up.'); + + console.log('[setup] Running onboarding...'); + api = await request.newContext({ baseURL: baseUrl }); + + let res = await api.post('/api/onboarding/validate-admin', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + }); + if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); + + res = await api.post('/api/onboarding/setup', { + headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); + + console.log('[setup] Logging in...'); + res = await api.post('/api/auth/login', { + data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, + }); + if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); + const { sessionId } = (await res.json()) as { sessionId: string }; + state.sessionId = sessionId; + writeState(state); + + console.log('[setup] Setting FROSTR credentials on server...'); + res = await api.post('/api/user/credentials', { headers: { 'X-Session-ID': sessionId }, - data: { message: TEST_MSG }, - }).catch(() => null); - if (sr && sr.ok()) { - signOk = true; - console.log(`[setup] Signing OK on attempt ${attempt}.`); - break; + data: { + group_cred: groupCredential, + share_cred: shareCredentials[0], + relays: [`ws://${host}:${port}`], + }, + }); + if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); + + await pollUntil( + async () => { + const s = await api!.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); + if (!s.ok()) return false; + const body = (await s.json()) as { nodeActive: boolean }; + return body.nodeActive === true; + }, + 15_000, + 1000, + 'nodeActive = true', + ); + console.log('[setup] Node is active.'); + + console.log('[setup] Starting co-signer with shareCredentials[1]...'); + cosignerProcess = spawnDetached( + 'node', + [ + path.resolve('tests/e2e/cosigner.mjs'), + groupCredential, + shareCredentials[1], + `ws://${host}:${port}`, + ], + {}, + COSIGNER_LOG, + ); + state.cosignerPid = cosignerProcess.pid ?? 0; + writeState(state); + console.log('[setup] Co-signer pid:', cosignerProcess.pid); + + console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); + const TEST_MSG = 'a'.repeat(64); + let signOk = false; + for (let attempt = 1; attempt <= 5; attempt++) { + await sleep(3000); + const sr = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: TEST_MSG }, + }).catch(() => null); + if (sr && sr.ok()) { + signOk = true; + console.log(`[setup] Signing OK on attempt ${attempt}.`); + break; + } + const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; + console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); + } + if (!signOk) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error(`Co-signer did not become ready within 15 s.\nCo-signer log:\n${cosLog}`); } - const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; - console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); - } - if (!signOk) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within 25 s.\nCo-signer log:\n${cosLog}`); - } - // ── 9. Create persistent test API key ───────────────────────────────────── - console.log('[setup] Creating test API key…'); - res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'smoke-test-key' }, - }); - let apiKey: string | null = null; - let apiKeyId: string | null = null; - if (res.ok()) { - const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; - apiKey = body.apiKey.token; - apiKeyId = String(body.apiKey.id); - } else { - console.warn('[setup] Could not create API key – admin key tests will be skipped.'); - } + console.log('[setup] Creating test API key...'); + res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'smoke-test-key' }, + }); + if (res.ok()) { + const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; + state.apiKey = body.apiKey.token; + state.apiKeyId = String(body.apiKey.id); + } else { + console.warn('[setup] Could not create API key - auth tests will skip API-key checks.'); + } - await api.dispose(); + await api.dispose(); + api = null; - // ── 10. Persist shared state ─────────────────────────────────────────────── - const state = { - port: PORT, - baseUrl: BASE_URL, - tmpDir: TMP_DIR, - serverPid: serverProcess.pid, - cosignerPid: cosignerProcess.pid, - sessionId, - apiKey, - apiKeyId, - groupCredential, - shareCredentials, - groupPubkeyHex, - adminUsername: ADMIN_USERNAME, - adminPassword: ADMIN_PASSWORD, - adminSecret: ADMIN_SECRET, - }; + writeState(state); + console.log('[setup] Global setup complete. State saved to', STATE_FILE); + } catch (err) { + if (api) { + try { + await api.dispose(); + } catch { + // no-op + } + } - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); - process.env.SMOKE_STATE_FILE = STATE_FILE; + terminateProcess(cosignerProcess, 'co-signer'); + terminateProcess(serverProcess, 'server'); - console.log('[setup] ✓ Global setup complete. State saved to', STATE_FILE); + // Persist whatever we have so teardown can still clean up. + try { + writeState(state); + } catch { + // no-op + } + throw err; + } } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index f4ffd72..d087494 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -4,18 +4,44 @@ */ import fs from 'fs'; +import os from 'os'; +import path from 'path'; import type { FullConfig } from '@playwright/test'; +function findLatestStateFile(): string | null { + const tmpRoot = os.tmpdir(); + let latestFile: string | null = null; + let latestMtime = 0; + + for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; + const candidate = path.join(tmpRoot, entry.name, 'state.json'); + if (!fs.existsSync(candidate)) continue; + const mtime = fs.statSync(candidate).mtimeMs; + if (mtime > latestMtime) { + latestMtime = mtime; + latestFile = candidate; + } + } + + return latestFile; +} + export default async function globalTeardown(_config: FullConfig): Promise { const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile || !fs.existsSync(stateFile)) { + const resolvedStateFile = + stateFile && fs.existsSync(stateFile) + ? stateFile + : findLatestStateFile(); + + if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { console.warn('[teardown] No state file found – nothing to clean up.'); return; } let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; try { - state = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')); } catch { console.warn('[teardown] Could not parse state file.'); return; @@ -37,10 +63,11 @@ export default async function globalTeardown(_config: FullConfig): Promise // Brief pause to let processes flush logs await new Promise(r => setTimeout(r, 500)); - if (state.tmpDir) { + const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); + if (tmpDir) { try { - fs.rmSync(state.tmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', state.tmpDir); + fs.rmSync(tmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', tmpDir); } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 91fba5c..4ac2a8e 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -65,20 +65,20 @@ test.describe('Auth – /api/auth', () => { await api.dispose(); }); - test('GET /api/status – valid API key (X-API-Key) returns 200', async () => { + test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { test.skip(!apiKey, 'No API key available'); const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { + const res = await api.get('/api/event-log', { headers: { 'X-API-Key': apiKey! }, }); expect(res.status()).toBe(200); await api.dispose(); }); - test('GET /api/status – valid API key (Bearer) returns 200', async () => { + test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { test.skip(!apiKey, 'No API key available'); const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { + const res = await api.get('/api/event-log', { headers: { Authorization: `Bearer ${apiKey!}` }, }); expect(res.status()).toBe(200); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 470be6e..4c14af2 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -51,8 +51,8 @@ test.describe('Admin – API keys', () => { }); const { apiKey } = await createRes.json(); - // Use key to hit a protected route - const authRes = await api.get('/api/status', { + // Use key to hit an auth-protected route (status is intentionally public). + const authRes = await api.get('/api/event-log', { headers: { 'X-API-Key': apiKey.token }, }); expect(authRes.status()).toBe(200); From d3c38a21197dee638dcbd8f99eb8d3d6079b2ed0 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Fri, 20 Feb 2026 22:06:55 -0600 Subject: [PATCH 26/69] fix: move env admin gate before validation, fix Bearer token parsing, and IPv6 loopback filter --- .github/workflows/ci.yml | 2 +- .gitignore | 1 + llm/implementation/e2e-smoke-tests.md | 10 ++-- llm/implementation/umbrel-implementation.md | 2 +- scripts/release.sh | 6 +-- src/routes/env.ts | 53 +++++++++++------- src/routes/utils.test.ts | 24 ++++++++- src/routes/utils.ts | 13 +++-- test-results/.last-run.json | 4 -- tests/e2e/cosigner.mjs | 5 +- tests/e2e/global-setup.ts | 59 ++++++++++++++------- tests/e2e/global-teardown.ts | 17 +++++- tests/e2e/helpers.ts | 16 ++++++ tests/e2e/specs/01-auth.e2e.ts | 1 + tests/e2e/specs/02-status-peers.e2e.ts | 1 + tests/e2e/specs/03-nip44-nip04.e2e.ts | 10 ++++ tests/e2e/specs/06-event-log.e2e.ts | 1 + tests/e2e/specs/07-env.e2e.ts | 14 +++++ tests/e2e/specs/08-ui.e2e.ts | 26 ++------- tests/e2e/state.ts | 37 +++++++++++-- tests/routes/env.db-mode.spec.ts | 3 +- 21 files changed, 217 insertions(+), 88 deletions(-) delete mode 100644 test-results/.last-run.json create mode 100644 tests/e2e/helpers.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4bfef2..732d97c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,7 @@ jobs: exit 1 - name: Check for secrets - uses: trufflesecurity/trufflehog@main + uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ extra_args: --debug --only-verified diff --git a/.gitignore b/.gitignore index bf245db..2817c75 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ data/.session-secret test-*.sh debug-*.js verify-*.md +test-results/ .DS_Store # LLM files diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 5c40865..6ecd3a8 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -32,11 +32,11 @@ npx playwright show-report Prerequisites: - `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). - `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). -- No other process listening on port 18002. +- Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. ## File Structure -``` +```text tests/e2e/ ├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json ├── global-teardown.ts # SIGTERMs both processes, deletes temp dir @@ -90,7 +90,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l ### 3. Complete onboarding -``` +```text POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) POST /api/onboarding/setup (creates admin user with username + password) POST /api/auth/login → sessionId @@ -98,7 +98,7 @@ POST /api/auth/login → sessionId ### 4. Set FROSTR credentials -``` +```text POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } ``` @@ -331,7 +331,7 @@ Without this fix, the server's relay would reject all subscriptions from the bif Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): -``` +```text igloo-smoke-test/ ├── db/ # SQLite database files (igloo.db, .session-secret) ├── state.json # Shared test state (pids, session, credentials, etc.) diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 22353b5..a3e7955 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -71,6 +71,6 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update the digest in `igloo-server-store/igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). +2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). 3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/scripts/release.sh b/scripts/release.sh index e161490..c2c57b0 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -73,13 +73,9 @@ for i in {1..5}; do fi done -if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server failed to respond after 5 attempts" -fi - # Cleanup will be handled by trap, just check if we should fail if [ "$SERVER_HEALTHY" = false ]; then - echo "❌ Server startup test failed - cannot proceed with release" + echo "❌ Server failed to respond after 5 attempts - cannot proceed with release" exit 1 fi diff --git a/src/routes/env.ts b/src/routes/env.ts index d376827..3bc16e0 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -276,6 +276,21 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged const env = await readEnvFile(); const { validKeys, invalidKeys: rejectedKeys } = validateEnvKeys(Object.keys(body)); + // DB mode privilege gate for env writes (no legacy fallback): + // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or + // - allow when the authenticated DB user has role=admin. + // validateAdminSecret() returns false when the header is missing; there is no bypass. + const authHeader = req.headers.get('Authorization'); + const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; + const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; + const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + if (!isAdminSecret && !isRoleAdmin) { + return Response.json( + { error: 'Admin privileges required for environment modifications' }, + { status: 403, headers } + ); + } + if (validKeys.includes('RELAYS') && body.RELAYS !== undefined) { const relayValidation = validateRelayUrls(body.RELAYS); if (!relayValidation.valid) { @@ -283,33 +298,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } - if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED) { + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { const groupValidation = validateGroup(body.GROUP_CRED); if (!groupValidation.isValid) { return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); } } - if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED) { + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { const shareValidation = validateShare(body.SHARE_CRED); if (!shareValidation.isValid) { return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); } } - // DB mode privilege gate for env writes (no legacy fallback): - // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or - // - allow when the authenticated DB user has role=admin. - // validateAdminSecret() returns false when the header is missing; there is no bypass. - const adminSecret = req.headers.get('X-Admin-Secret') ?? req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); - const isAdminSecret = await validateAdminSecret(adminSecret); - if (!isAdminSecret && !isRoleAdmin) { - return Response.json( - { error: 'Admin privileges required for environment modifications' }, - { status: 403, headers } - ); - } - for (const key of validKeys) { if (body[key] !== undefined) { env[key] = body[key]; @@ -364,6 +366,20 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged } } + if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { + const groupValidation = validateGroup(body.GROUP_CRED); + if (!groupValidation.isValid) { + return Response.json({ success: false, error: 'Invalid GROUP_CRED' }, { status: 400, headers }); + } + } + + if (validKeys.includes('SHARE_CRED') && body.SHARE_CRED !== undefined) { + const shareValidation = validateShare(body.SHARE_CRED); + if (!shareValidation.isValid) { + return Response.json({ success: false, error: 'Invalid SHARE_CRED' }, { status: 400, headers }); + } + } + for (const key of validKeys) { if (body[key] !== undefined) { env[key] = body[key]; @@ -643,9 +659,10 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } if (!HEADLESS) { - const adminSecret = req.headers.get('X-Admin-Secret') ?? - req.headers.get('Authorization')?.replace(/^Bearer\s+/i, ''); - const isAdminSecret = await validateAdminSecret(adminSecret); + const authHeader = req.headers.get('Authorization'); + const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; + const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; + const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for deleting environment variables' }, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 6236e1e..53f1d71 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'bun:test'; -import { getValidRelays } from './utils.js'; +import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; describe('getValidRelays', () => { it('returns default relay when fallback is enabled and input is empty', () => { @@ -20,4 +20,26 @@ describe('getValidRelays', () => { it('filters invalid relays and returns empty when fallback disabled', () => { expect(getValidRelays('["not-a-relay","ftp://example.com"]', { fallbackToDefault: false })).toEqual([]); }); + + it('filters IPv6 localhost relay when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); + + it('keeps localhost relay in echo list when explicitly allowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'true'; + try { + expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index b1e31b4..8d49880 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -32,6 +32,11 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { return hex.toLowerCase(); } +function isLoopbackRelayHost(hostname: string): boolean { + const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + // Helper function to get valid relay URLs export function getValidRelays( envRelays?: string, @@ -66,7 +71,7 @@ export function getValidRelays( const url = new URL(relay); // Exclude localhost relays to avoid conflicts with our server // (unless explicitly allowed, e.g. for testing) - if (!allowLocalhost && (url.hostname === 'localhost' || url.hostname === '127.0.0.1')) { + if (!allowLocalhost && isLoopbackRelayHost(url.hostname)) { console.warn(`Excluding localhost relay to avoid conflicts: ${relay}`); return false; } @@ -797,14 +802,16 @@ export function validateRelayUrls(relays: any): { valid: boolean; urls?: string[ export function normalizeRelayListForEcho(relays: any): string[] | undefined { const validation = validateRelayUrls(relays); if (!validation.valid || !validation.urls || validation.urls.length === 0) return undefined; + const allowLocalhost = process.env['ALLOW_LOCALHOST_RELAY'] === 'true'; const filtered = validation.urls .map((r) => r.trim()) .filter((r) => r.length > 0) .filter((r) => { try { const u = new URL(r); - return (u.protocol === 'ws:' || u.protocol === 'wss:') && - u.hostname !== 'localhost' && u.hostname !== '127.0.0.1' && u.hostname !== '::1'; + if (u.protocol !== 'ws:' && u.protocol !== 'wss:') return false; + if (!allowLocalhost && isLoopbackRelayHost(u.hostname)) return false; + return true; } catch { return false; } diff --git a/test-results/.last-run.json b/test-results/.last-run.json deleted file mode 100644 index cbcc1fb..0000000 --- a/test-results/.last-run.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "status": "passed", - "failedTests": [] -} \ No newline at end of file diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 2c65947..100d88c 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -14,7 +14,7 @@ if (!groupCred || !shareCred || !relayUrl) { const { createBifrostNode, connectNode, -} = await import('../../node_modules/@frostr/igloo-core/dist/index.js'); +} = await import('@frostr/igloo-core'); let node; try { @@ -44,7 +44,8 @@ try { console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); console.log('[cosigner] Connected. Pubkey:', node.pubkey); - console.log('[cosigner] Filter:', JSON.stringify(node.client?._filter ?? node.client?.filter ?? '?')); + // `_filter` is a private fallback for older client internals. + console.log('[cosigner] Filter:', JSON.stringify(node.client?.filter ?? node.client?._filter ?? '?')); } catch (err) { console.error('[cosigner] Failed to start:', err.message ?? err); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index bbc1534..33e9c59 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -31,13 +31,11 @@ const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); -// A fixed, deterministic 32-byte secp256k1 private key (well below curve order) -const TEST_NSEC_HEX = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; - -// Meets igloo-server password rules: upper + lower + digit + special(@), no sequences -const ADMIN_SECRET = 'SmokeTestAdmin1'; -const ADMIN_USERNAME = 'testadmin'; -const ADMIN_PASSWORD = 'T3stPass@9'; +// Defaults are safe for local CI, but callers should override via environment variables. +const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'SmokeTestAdmin1'; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? 'testadmin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'T3stPass@9'; type SetupState = { port: number; @@ -77,6 +75,8 @@ function terminateProcess(proc: ChildProcess | null, label: string) { } } +// Port probing is inherently TOCTOU: we can only test availability now, not reserve it +// forever. For CI smoke tests this low-probability race is acceptable. function canBindPort(port: number, host: string): Promise { return new Promise(resolve => { const srv = net.createServer(); @@ -87,6 +87,8 @@ function canBindPort(port: number, host: string): Promise { }); } +// Reserve an ephemeral port by binding to :0 and immediately closing; another process +// could still claim it before spawn, but this is sufficient for smoke test setup. function reserveRandomPort(host: string): Promise { return new Promise((resolve, reject) => { const srv = net.createServer(); @@ -99,12 +101,14 @@ function reserveRandomPort(host: string): Promise { }); } +// Prefer the requested port, but fall back when busy. This does not eliminate the +// bind race between probing and process startup. async function resolvePort(host: string, preferredPort: number): Promise { if (await canBindPort(preferredPort, host)) { return preferredPort; } const fallbackPort = await reserveRandomPort(host); - console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort}`); + console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); return fallbackPort; } @@ -115,11 +119,18 @@ async function pollUntil( label = 'condition', ): Promise { const deadline = Date.now() + timeoutMs; + let consecutiveFailures = 0; while (Date.now() < deadline) { try { if (await fn()) return; - } catch { - // ignore and keep polling + consecutiveFailures = 0; + } catch (error) { + consecutiveFailures += 1; + const nearingDeadline = Date.now() + intervalMs >= deadline; + if (consecutiveFailures === 3 || nearingDeadline) { + const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); + console.warn(`[setup] pollUntil(${label}) transient failure x${consecutiveFailures}: ${detail}`); + } } await sleep(intervalMs); } @@ -145,15 +156,19 @@ function spawnDetached( logFile: string, ): ChildProcess { const out = fs.openSync(logFile, 'a'); - const proc = spawn(cmd, args, { - env: { ...process.env, ...env }, - detached: false, - stdio: ['ignore', out, out], - }); - proc.on('error', err => { - fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); - }); - return proc; + try { + const proc = spawn(cmd, args, { + env: { ...process.env, ...env }, + detached: false, + stdio: ['ignore', out, out], + }); + proc.on('error', err => { + fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); + }); + return proc; + } finally { + fs.closeSync(out); + } } export default async function globalSetup(_config: FullConfig): Promise { @@ -305,6 +320,12 @@ export default async function globalSetup(_config: FullConfig): Promise { const TEST_MSG = 'a'.repeat(64); let signOk = false; for (let attempt = 1; attempt <= 5; attempt++) { + if (cosignerProcess && cosignerProcess.exitCode !== null) { + const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; + throw new Error( + `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` + ); + } await sleep(3000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index d087494..0dbb322 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -66,8 +66,21 @@ export default async function globalTeardown(_config: FullConfig): Promise const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); if (tmpDir) { try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', tmpDir); + const resolvedTmp = path.resolve(tmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + } else { + fs.rmSync(resolvedTmp, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', resolvedTmp); + } } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts new file mode 100644 index 0000000..37f6e99 --- /dev/null +++ b/tests/e2e/helpers.ts @@ -0,0 +1,16 @@ +import type { Page } from '@playwright/test'; + +export async function loginAs(page: Page, username: string, password: string): Promise { + const usernameField = page + .locator('input[type="text"], input[id*="user"], input[name*="user"], input[name*="ur"]') + .first(); + const passwordField = page.locator('input[type="password"]').first(); + const submitBtn = page + .locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")') + .first(); + + await usernameField.fill(username); + await passwordField.fill(password); + await submitBtn.click(); + await page.waitForLoadState('networkidle'); +} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 4ac2a8e..e37a56b 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -100,6 +100,7 @@ test.describe('Auth – /api/auth', () => { const loginRes = await api.post('/api/auth/login', { data: { username: adminUsername, password: adminPassword }, }); + expect(loginRes.status()).toBe(200); const { sessionId: tempSession } = await loginRes.json(); const logoutRes = await api.post('/api/auth/logout', { diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index 2190c8c..0066a79 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -38,6 +38,7 @@ test.describe('Status – /api/status', () => { const res = await api.get('/api/status', { headers: { 'X-Session-ID': sessionId }, }); + expect(res.status()).toBe(200); const body = await res.json(); expect(body.health).toHaveProperty('isConnected'); expect(typeof body.health.isConnected).toBe('boolean'); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index cd118f4..60b3353 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -137,4 +137,14 @@ test.describe('NIP-04 – /api/nip04', () => { expect(res.status()).toBe(400); await api.dispose(); }); + + test('missing content returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 4c85213..50d7ec0 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -34,6 +34,7 @@ test.describe('Event log – /api/event-log', () => { const res = await api.get('/api/event-log', { headers: { 'X-Session-ID': sessionId }, }); + expect(res.status()).toBe(200); const body = await res.json(); // If there are entries from sign tests, validate their shape diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 0ced112..61017e3 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -74,6 +74,20 @@ test.describe('Env / credentials – /api/env', () => { await api.dispose(); }); + test('POST /api/env – empty RELAYS returns 400', async () => { + const api = await request.newContext({ baseURL: baseUrl }); + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: [], + }, + }); + expect(res.status()).toBe(400); + await api.dispose(); + }); + test('POST /api/env without auth returns 401', async () => { const api = await request.newContext({ baseURL: baseUrl }); const res = await api.post('/api/env', { diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 8c38bc5..d73ebfe 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect } from '@playwright/test'; +import { loginAs } from '../helpers.js'; import { loadState } from '../state.js'; const state = loadState(); @@ -16,7 +17,7 @@ test.describe('UI – Login page', () => { await page.goto(baseUrl); // The SPA should show either the login form or onboarding // Since onboarding is complete, we expect the login form - await expect(page).toHaveURL(baseUrl + '/'); + await expect(page).toHaveURL(new URL('/', baseUrl).toString()); // Login form has username + password inputs await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ timeout: 10_000, @@ -26,17 +27,7 @@ test.describe('UI – Login page', () => { test('login form accepts credentials and navigates to dashboard', async ({ page }) => { await page.goto(baseUrl); - - // Fill in the login form - const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); - const passwordField = page.locator('input[type="password"]').first(); - - await usernameField.fill(adminUsername); - await passwordField.fill(adminPassword); - - // Submit (button with type=submit or labeled "Login"/"Sign in") - const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); - await submitBtn.click(); + await loginAs(page, adminUsername, adminPassword); // After login we should see the main app tabs await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ @@ -49,16 +40,7 @@ test.describe('UI – Authenticated app', () => { // Log in once per test block using page fixtures (each test gets a fresh page) test.beforeEach(async ({ page }) => { await page.goto(baseUrl); - - const usernameField = page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first(); - const passwordField = page.locator('input[type="password"]').first(); - await usernameField.fill(adminUsername); - await passwordField.fill(adminPassword); - const submitBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first(); - await submitBtn.click(); - - // Wait for the app to load - await page.waitForLoadState('networkidle'); + await loginAs(page, adminUsername, adminPassword); }); test('Signer tab is visible and shows node status indicator', async ({ page }) => { diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 76616f5..c7d9206 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { z } from 'zod'; export interface SmokeTestState { port: number; @@ -17,6 +18,27 @@ export interface SmokeTestState { adminSecret: string; } +/** + * Zod schema for SmokeTestState persisted by global-setup. + * Validates shape and types before returning from loadState. + */ +const smokeTestStateSchema = z.object({ + port: z.number().int().positive(), + baseUrl: z.string().min(1, 'baseUrl must be non-empty'), + tmpDir: z.string(), + serverPid: z.number().int().nonnegative(), + cosignerPid: z.number().int().nonnegative(), + sessionId: z.string().min(1, 'sessionId must be non-empty'), + apiKey: z.string().nullable(), + apiKeyId: z.string().nullable(), + groupCredential: z.string(), + shareCredentials: z.array(z.string()), + groupPubkeyHex: z.string(), + adminUsername: z.string(), + adminPassword: z.string(), + adminSecret: z.string(), +}); + const STUB: SmokeTestState = { port: 18002, baseUrl: 'http://localhost:18002', @@ -44,8 +66,17 @@ export function loadState(): SmokeTestState { const stateFile = process.env.SMOKE_STATE_FILE; if (!stateFile) return STUB; try { - return JSON.parse(fs.readFileSync(stateFile, 'utf8')) as SmokeTestState; - } catch { - return STUB; + const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + const result = smokeTestStateSchema.safeParse(parsed); + if (!result.success) { + const issues = result.error.issues + .map(i => `${i.path.join('.')}: ${i.message}`) + .join('; '); + throw new Error(`validation failed: ${issues}`); + } + return result.data as SmokeTestState; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); } } diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 91d1c2c..67abb95 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -104,7 +104,6 @@ describe('DB-mode /api/env behavior', () => { const out = runRouteScript(script); expect(out.hasStamp).toBeTrue(); - // Status may be 200 on success or 500 if restart failed; accept either - expect([200, 500]).toContain(out.status); + expect(out.status).toBe(200); }, { timeout: 10000 }); }); From 3a6cb0bf75ff1a15ff8ea6bcca48dad29a2ed4c2 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 09:42:51 -0600 Subject: [PATCH 27/69] fix: reject empty RELAYS in /api/env updates --- src/routes/env.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/routes/env.ts b/src/routes/env.ts index 3bc16e0..d204929 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -296,6 +296,9 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (!relayValidation.valid) { return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } } if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { @@ -364,6 +367,9 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (!relayValidation.valid) { return Response.json({ success: false, error: relayValidation.error }, { status: 400, headers }); } + if (!relayValidation.urls || relayValidation.urls.length === 0) { + return Response.json({ success: false, error: 'At least one relay URL is required' }, { status: 400, headers }); + } } if (validKeys.includes('GROUP_CRED') && body.GROUP_CRED !== undefined) { From 156ce53500ffd0741aada6eee8d2379655ce9426 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 11:26:03 -0600 Subject: [PATCH 28/69] fix: expand 127.0.0.0/8 loopback filter, harden E2E cosigner, and clean up imports --- .github/workflows/ci.yml | 2 +- .gitignore | 1 - frontend/components/ui/peer-list.tsx | 1 + llm/implementation/e2e-smoke-tests.md | 4 +-- package.json | 5 ++-- playwright.config.ts | 1 - src/routes/utils.test.ts | 13 ++++++++++ src/routes/utils.ts | 6 ++++- tests/e2e/cosigner.mjs | 35 ++++++++++++++++++++++++-- tests/e2e/global-setup.ts | 5 +--- tests/e2e/global-teardown.ts | 1 + tests/e2e/helpers.ts | 2 +- tests/e2e/specs/02-status-peers.e2e.ts | 2 +- tests/e2e/specs/04-sign.e2e.ts | 1 + tests/e2e/specs/05-admin.e2e.ts | 1 + tests/e2e/specs/06-event-log.e2e.ts | 14 +++++------ tests/e2e/state.ts | 2 +- tests/routes/helpers/script-runner.ts | 7 +++++- 18 files changed, 77 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 732d97c..df1c441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: run: bun run tsc --noEmit - name: Run route tests - run: bun test tests/routes + run: bun run test:unit - name: Build frontend run: bun run build diff --git a/.gitignore b/.gitignore index 2817c75..eda8834 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ test-*.sh debug-*.js verify-*.md test-results/ -.DS_Store # LLM files .claude diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 96adf38..4f65b5c 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -597,6 +597,7 @@ const PeerList: React.FC = ({ className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-800/50 p-2.5 rounded cursor-pointer hover:bg-gray-800/70 transition-colors gap-2 sm:gap-0" onClick={handleToggle} role="button" + aria-expanded={isExpanded} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 6ecd3a8..ca6ff00 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -192,8 +192,8 @@ Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encryp - 400 for non-hex message - 400 for message shorter than 32 bytes - 400 for missing body -- Signs a 32-byte hex message; response contains `sig` and `pubkey` -- Signs a full Nostr event object (with `id`, `content`, `kind`, `created_at`, `tags`) +- Signs a 32-byte hex message; response contains `id` and `signature` +- Signs a full Nostr event object (with `id`, `pubkey`, `content`, `kind`, `created_at`, `tags`) - Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing - 400 for event with invalid pubkey diff --git a/package.json b/package.json index 02cdb89..4c98c50 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "test:unit": "bun test --max-concurrency=1 src tests/routes", "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", "test:e2e": "npx playwright test", - "test:e2e:nightly": "npx playwright test", + "test:e2e:nightly": "npx playwright test --project=api --project=ui --retries=2 --timeout=60000", "test:e2e:ui": "npx playwright test --project=ui", "test:e2e:api": "npx playwright test --project=api", "test:e2e:report": "npx playwright show-report", @@ -62,7 +62,8 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", - "yaml": "^2.8.1" + "yaml": "^2.8.1", + "zod": "^3.25.76" }, "devDependencies": { "@playwright/test": "^1.58.2", diff --git a/playwright.config.ts b/playwright.config.ts index 384184a..42fdb5b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -18,7 +18,6 @@ export default defineConfig({ ], use: { - baseURL: 'http://localhost:18002', trace: 'on-first-retry', // Longer default for operations that wait on bifrost relay round-trips actionTimeout: 15_000, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 53f1d71..629ae27 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -31,7 +31,20 @@ describe('getValidRelays', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); +}); +describe('normalizeRelayListForEcho', () => { it('keeps localhost relay in echo list when explicitly allowed', () => { const previous = process.env.ALLOW_LOCALHOST_RELAY; process.env.ALLOW_LOCALHOST_RELAY = 'true'; diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 8d49880..c9086fe 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -34,7 +34,11 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { function isLoopbackRelayHost(hostname: string): boolean { const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; + if (normalized === 'localhost' || normalized === '::1') return true; + const octets = normalized.split('.'); + if (octets.length !== 4) return false; + if (octets[0] !== '127') return false; + return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); } // Helper function to get valid relay URLs diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 100d88c..a598fbb 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -16,6 +16,28 @@ const { connectNode, } = await import('@frostr/igloo-core'); +/** + * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid + * "Converting circular structure to JSON" TypeError from bubbling into outer catch. + * @param {unknown} obj - Value to serialize + * @returns {string} JSON string or fallback representation + */ +function safeStringify(obj) { + const seen = new WeakSet(); + function replacer(_key, value) { + if (typeof value === 'object' && value !== null) { + if (seen.has(value)) return '[Circular]'; + seen.add(value); + } + return value; + } + try { + return JSON.stringify(obj, replacer); + } catch (e) { + return '[Non-serializable]'; + } +} + let node; try { node = createBifrostNode({ @@ -44,8 +66,17 @@ try { console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); console.log('[cosigner] Connected. Pubkey:', node.pubkey); - // `_filter` is a private fallback for older client internals. - console.log('[cosigner] Filter:', JSON.stringify(node.client?.filter ?? node.client?._filter ?? '?')); + const filter = node.client?.filter; + const privateFilter = node.client?._filter; + if (filter !== undefined) { + console.log('[cosigner] Filter (public):', safeStringify(filter)); + } else if (privateFilter !== undefined) { + // TODO: Remove private fallback once @frostr/igloo-core exposes a stable public filter accessor. + console.warn('[cosigner] Filter fallback in use: node.client._filter (private internals)'); + console.log('[cosigner] Filter (private fallback):', safeStringify(privateFilter)); + } else { + console.warn('[cosigner] Filter unavailable on node.client (public and private fields missing)'); + } } catch (err) { console.error('[cosigner] Failed to start:', err.message ?? err); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 33e9c59..ade0314 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -206,10 +206,7 @@ export default async function globalSetup(_config: FullConfig): Promise { writeState(state); console.log('[setup] Generating FROSTR credentials...'); - const { generateKeysetWithSecret, decodeGroup } = await import( - // @ts-ignore - '../../node_modules/@frostr/igloo-core/dist/index.js' - ) as { + const { generateKeysetWithSecret, decodeGroup } = await import('@frostr/igloo-core') as { generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; }; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 0dbb322..64e26f1 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -33,6 +33,7 @@ export default async function globalTeardown(_config: FullConfig): Promise stateFile && fs.existsSync(stateFile) ? stateFile : findLatestStateFile(); + console.log('[teardown] Resolved state file:', resolvedStateFile ?? '(none)'); if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { console.warn('[teardown] No state file found – nothing to clean up.'); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 37f6e99..a4cd656 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -2,7 +2,7 @@ import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { const usernameField = page - .locator('input[type="text"], input[id*="user"], input[name*="user"], input[name*="ur"]') + .locator('input[type="text"], input[id*="user"], input[name*="user"]') .first(); const passwordField = page.locator('input[type="password"]').first(); const submitBtn = page diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index 0066a79..d5b4e2b 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -65,7 +65,7 @@ test.describe('Peers – /api/peers', () => { const body = await res.json(); expect(body).toHaveProperty('peers'); expect(Array.isArray(body.peers)).toBe(true); - // 2-of-3 keyset: 2 remote peers (self filtered out) + // 2-of-2 keyset: 1 remote peer (self filtered out) expect(body.peers.length).toBeGreaterThanOrEqual(1); expect(typeof body.total).toBe('number'); expect(typeof body.online).toBe('number'); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 8e8d943..65d3d72 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -16,6 +16,7 @@ const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; test.describe('Sign – /api/sign', () => { + // Explicit per-suite timeout for signing flows; global timeout is also 30_000. test.setTimeout(30_000); test('returns 401 without auth', async () => { diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 4c14af2..e3cff89 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -49,6 +49,7 @@ test.describe('Admin – API keys', () => { headers: { 'X-Session-ID': sessionId }, data: { label: 'auth-test-key' }, }); + expect(createRes.status()).toBe(201); const { apiKey } = await createRes.json(); // Use key to hit an auth-protected route (status is intentionally public). diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 50d7ec0..3bcda19 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -36,14 +36,12 @@ test.describe('Event log – /api/event-log', () => { }); expect(res.status()).toBe(200); const body = await res.json(); - - // If there are entries from sign tests, validate their shape - if (body.entries.length > 0) { - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - } + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBeGreaterThan(0); + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); await api.dispose(); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index c7d9206..033695a 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -74,7 +74,7 @@ export function loadState(): SmokeTestState { .join('; '); throw new Error(`validation failed: ${issues}`); } - return result.data as SmokeTestState; + return result.data; } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 87db273..e1f2aa7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -32,7 +32,12 @@ const ISOLATED_ENV_PREFIXES = [ ]; function buildScriptEnv(overrides: Record): Record { - const nextEnv: Record = { ...process.env } as Record; + const nextEnv: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === 'string') { + nextEnv[key] = value; + } + } for (const key of ISOLATED_ENV_KEYS) { delete nextEnv[key]; From 33e14cdc2bf8f3c01a8194f5b4daa8a15dbb0d7d Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 12:33:40 -0600 Subject: [PATCH 29/69] fix: lazy ENV_FILE_PATH eval, peer-list hook deps/a11y, CI audit output, and E2E scaffolding --- .github/workflows/ci.yml | 15 +- .github/workflows/release.yml | 2 +- frontend/components/ui/peer-list.tsx | 20 +- llm/implementation/e2e-smoke-tests.md | 2 +- .../node-lifecycle-implementation.md | 2 +- src/routes/utils.test.ts | 22 ++ src/routes/utils.ts | 15 +- tests/e2e/cosigner.mjs | 6 +- tests/e2e/global-setup.ts | 47 +++-- tests/e2e/global-teardown.ts | 64 +++--- tests/e2e/helpers.ts | 5 + tests/e2e/smoke-test-defaults.json | 6 + tests/e2e/specs/04-sign.e2e.ts | 28 ++- tests/e2e/specs/05-admin.e2e.ts | 194 +++++++++--------- tests/e2e/specs/08-ui.e2e.ts | 9 +- tests/e2e/state.ts | 4 +- tests/routes/env.db-mode.spec.ts | 8 +- tests/routes/helpers/script-runner.ts | 19 +- 18 files changed, 279 insertions(+), 189 deletions(-) create mode 100644 tests/e2e/smoke-test-defaults.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df1c441..3865c2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,8 +84,11 @@ jobs: - name: Run security audit run: | + audit_log="$(mktemp)" for attempt in 1 2 3; do - if bun audit; then + if bun audit >"$audit_log" 2>&1; then + cat "$audit_log" + rm -f "$audit_log" exit 0 fi if [ "$attempt" -lt 3 ]; then @@ -93,10 +96,18 @@ jobs: sleep 5 fi done - echo "bun audit failed after retries" + + audit_output="$(cat "$audit_log")" + if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then + echo "bun audit failed after retries due to network/registry error: $audit_output" + else + echo "bun audit failed after retries - vulnerabilities detected: $audit_output" + fi + rm -f "$audit_log" exit 1 - name: Check for secrets + # Pinned to immutable commit (v3.93.4) for supply-chain safety. uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1627643..cf3579b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,7 +74,7 @@ jobs: echo "version_number=${NEW_VERSION#v}" >> $GITHUB_OUTPUT - name: Type check - run: bun run typecheck + run: bun run tsc --noEmit - name: Run backend tests run: bun run test:unit diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 4f65b5c..301fbe8 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -390,7 +390,7 @@ const PeerList: React.FC = ({ window.removeEventListener('peerStatusUpdate', handlePeerUpdate as EventListener); window.removeEventListener('peerPingUpdate', handlePeerUpdate as EventListener); }; - }, [isSignerRunning]); + }, [authHeaders, isSignerRunning]); // Ping individual peer const handlePingPeer = useCallback(async (peerPubkey: string) => { @@ -434,7 +434,7 @@ const PeerList: React.FC = ({ return newSet; }); } - }, [isSignerRunning]); + }, [authHeaders, isSignerRunning]); const updatePeerPolicy = useCallback(async (peer: PeerStatus, changes: { allowSend?: boolean; allowReceive?: boolean }) => { if (!isSignerRunning || disabled) { @@ -525,7 +525,7 @@ const PeerList: React.FC = ({ return; } try { - const response = await fetch('/api/peers/ping', { + await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -534,14 +534,12 @@ const PeerList: React.FC = ({ body: JSON.stringify({ target: 'all' }) }); - const result = await response.json(); - // Refresh peer list after pinging all await fetchPeers(); } catch (error) { console.warn('[PeerList] Ping all failed:', error); } - }, [isSignerRunning, peers.length, fetchPeers]); + }, [authHeaders, isSignerRunning, peers.length, fetchPeers]); // Enhanced refresh that includes pinging const handleRefresh = useCallback(async () => { @@ -656,7 +654,7 @@ const PeerList: React.FC = ({ )}
-
e.stopPropagation()} className="flex-shrink-0"> +
e.stopPropagation()} onKeyDown={e => e.stopPropagation()} className="flex-shrink-0"> {actions}
@@ -667,7 +665,9 @@ const PeerList: React.FC = ({ "transition-all duration-300 ease-in-out overflow-hidden", isExpanded ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0" )} + aria-hidden={!isExpanded} > + {isExpanded && (
{isLoading ? (
@@ -772,10 +772,7 @@ const PeerList: React.FC = ({
Policy: out {outboundPolicy.statusLabel}, in {inboundPolicy.statusLabel} - + {policyBadgeLabel}
@@ -898,6 +895,7 @@ const PeerList: React.FC = ({
)}
+ )}
); diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index ca6ff00..b4a4e8e 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -270,7 +270,7 @@ Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the For this reason: - The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. -- The "new API key can authenticate" test uses `GET /api/status` (public) which trivially returns 200; this confirms the key is created but does not actually exercise DB-key auth enforcement. +- The "new API key can authenticate" test uses `GET /api/event-log` to exercise real API-key auth enforcement on a protected endpoint. ### Event log export is NDJSON, not JSON diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index d132d4b..661c492 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset. Setting either to `false` forces publish errors to surface. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 629ae27..2f6f87f 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -42,6 +42,17 @@ describe('getValidRelays', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('filters localhost hostname relay when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); describe('normalizeRelayListForEcho', () => { @@ -55,4 +66,15 @@ describe('normalizeRelayListForEcho', () => { else process.env.ALLOW_LOCALHOST_RELAY = previous; } }); + + it('keeps localhost hostname in echo list when explicitly allowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'true'; + try { + expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index c9086fe..67d70e8 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -107,7 +107,9 @@ export function getValidRelays( } // Helper functions for .env file management -const ENV_FILE_PATH = process.env.ENV_FILE_PATH?.trim() || '.env'; +function getEnvFilePath(): string { + return process.env.ENV_FILE_PATH?.trim() || '.env'; +} // Security: Whitelist of allowed environment variable keys (for write/validation) // IMPORTANT: SESSION_SECRET must NEVER be included here - it's strictly server-only @@ -257,8 +259,9 @@ function stringifyEnvFile(env: Record): string { export async function readEnvFile(): Promise> { try { - await fs.access(ENV_FILE_PATH); - const content = await fs.readFile(ENV_FILE_PATH, 'utf-8'); + const envFilePath = getEnvFilePath(); + await fs.access(envFilePath); + const content = await fs.readFile(envFilePath, 'utf-8'); const fileEnv = parseEnvFile(content); // Merge with actual environment variables as fallback @@ -301,7 +304,7 @@ function getEnvVarsFromProcess(): Record { // Get the modification time of the environment file export async function getEnvFileModTime(): Promise { try { - const stats = await fs.stat(ENV_FILE_PATH); + const stats = await fs.stat(getEnvFilePath()); return stats.mtime.toISOString(); } catch (error) { // File doesn't exist or error accessing it @@ -365,7 +368,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr } const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); @@ -376,7 +379,7 @@ export async function writeEnvFileWithTimestamp(env: Record): Pr export async function writeEnvFile(env: Record): Promise { try { const content = stringifyEnvFile(env); - await fs.writeFile(ENV_FILE_PATH, content, 'utf-8'); + await fs.writeFile(getEnvFilePath(), content, 'utf-8'); return true; } catch (error) { console.error('Error writing .env file:', error); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index a598fbb..60eb439 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -52,16 +52,16 @@ try { }); node.on('closed', () => console.log('[cosigner] Node closed')); node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); - node.on('bounced', (...args) => console.log('[cosigner] Bounced:', JSON.stringify(args).slice(0, 200))); + node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); node.on('message', (msg) => { console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); }); node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); - node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', JSON.stringify(a).slice(0, 200))); + node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', safeStringify(a).slice(0, 200))); // Also spy on the raw WebSocket to confirm relay subscription - node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', JSON.stringify(a).slice(0, 100))); + node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); console.log('[cosigner] Connecting to relay:', relayUrl); await connectNode(node); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index ade0314..2c8d311 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -20,6 +20,7 @@ import fs from 'fs'; import net from 'net'; import os from 'os'; import path from 'path'; +import type { SmokeTestState } from './state.js'; const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); @@ -31,34 +32,38 @@ const DB_PATH = path.join(TMP_DIR, 'db'); const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); -// Defaults are safe for local CI, but callers should override via environment variables. -const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; -const ADMIN_SECRET = process.env.ADMIN_SECRET ?? 'SmokeTestAdmin1'; -const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? 'testadmin'; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'T3stPass@9'; - -type SetupState = { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; - apiKey: string | null; - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; - groupPubkeyHex: string; +const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); +const smokeDefaultsRaw = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); +if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { + throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); +} +const requiredKeys = ['testNsecHex', 'adminSecret', 'adminUsername', 'adminPassword'] as const; +const raw = smokeDefaultsRaw as Record; +const missing = requiredKeys.filter(k => raw[k] == null || typeof raw[k] !== 'string'); +if (missing.length > 0) { + throw new Error( + `smoke-test-defaults.json is missing required string properties: ${missing.join(', ')}. ` + + `Expected: ${requiredKeys.join(', ')}`, + ); +} +const smokeDefaults = raw as { + testNsecHex: string; + adminSecret: string; adminUsername: string; adminPassword: string; - adminSecret: string; }; +// Defaults come from fixture for local CI; callers can still override via environment. +const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; +const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; +const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; + function sleep(ms: number) { return new Promise(resolve => setTimeout(resolve, ms)); } -function writeState(state: SetupState) { +function writeState(state: SmokeTestState) { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); process.env.SMOKE_STATE_FILE = STATE_FILE; } @@ -176,7 +181,7 @@ export default async function globalSetup(_config: FullConfig): Promise { const port = await resolvePort(host, DEFAULT_PORT); const baseUrl = `http://${host}:${port}`; - const state: SetupState = { + const state: SmokeTestState = { port, baseUrl, tmpDir: TMP_DIR, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 64e26f1..bb4a4ad 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -7,21 +7,29 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import type { FullConfig } from '@playwright/test'; +import type { SmokeTestState } from './state.js'; function findLatestStateFile(): string | null { const tmpRoot = os.tmpdir(); let latestFile: string | null = null; let latestMtime = 0; - - for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; - const candidate = path.join(tmpRoot, entry.name, 'state.json'); - if (!fs.existsSync(candidate)) continue; - const mtime = fs.statSync(candidate).mtimeMs; - if (mtime > latestMtime) { - latestMtime = mtime; - latestFile = candidate; + try { + for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; + const candidate = path.join(tmpRoot, entry.name, 'state.json'); + if (!fs.existsSync(candidate)) continue; + try { + const mtime = fs.statSync(candidate).mtimeMs; + if (mtime > latestMtime) { + latestMtime = mtime; + latestFile = candidate; + } + } catch { + // Ignore transient stat/read errors when scanning tmp entries. + } } + } catch { + return null; } return latestFile; @@ -40,9 +48,9 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: { serverPid?: number; cosignerPid?: number; tmpDir?: string }; + let state: SmokeTestState; try { - state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')); + state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; } catch { console.warn('[teardown] Could not parse state file.'); return; @@ -65,25 +73,23 @@ export default async function globalTeardown(_config: FullConfig): Promise await new Promise(r => setTimeout(r, 500)); const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); - if (tmpDir) { - try { - const resolvedTmp = path.resolve(tmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); + try { + const resolvedTmp = path.resolve(tmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - } else { - fs.rmSync(resolvedTmp, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', resolvedTmp); - } - } catch (err) { - console.warn('[teardown] Could not remove temp dir:', err); + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + } else { + fs.rmSync(resolvedTmp, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', resolvedTmp); } + } catch (err) { + console.warn('[teardown] Could not remove temp dir:', err); } } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index a4cd656..375bb63 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -1,3 +1,4 @@ +import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { @@ -13,4 +14,8 @@ export async function loginAs(page: Page, username: string, password: string): P await passwordField.fill(password); await submitBtn.click(); await page.waitForLoadState('networkidle'); + await expect( + page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), + 'login failed: expected dashboard tabs after submit' + ).toBeVisible({ timeout: 10_000 }); } diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json new file mode 100644 index 0000000..298a223 --- /dev/null +++ b/tests/e2e/smoke-test-defaults.json @@ -0,0 +1,6 @@ +{ + "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", + "adminSecret": "SmokeTestAdmin1", + "adminUsername": "testadmin", + "adminPassword": "T3stPass@9" +} diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 65d3d72..60b2300 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -7,8 +7,17 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +type SignEventPayload = { + pubkey: string; + kind: number; + created_at: number; + content: string; + tags: string[][]; +}; + +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, groupPubkeyHex } = state; // Valid 32-byte hex event IDs for signing @@ -78,7 +87,7 @@ test.describe('Sign – /api/sign', () => { test('signs a full event object and returns signature', async () => { const api = await request.newContext({ baseURL: baseUrl }); // Use the group pubkey as the event author pubkey - const event = { + const event: SignEventPayload = { pubkey: groupPubkeyHex, kind: 1, created_at: Math.floor(Date.now() / 1000), @@ -113,16 +122,17 @@ test.describe('Sign – /api/sign', () => { test('event with invalid pubkey returns 400', async () => { const api = await request.newContext({ baseURL: baseUrl }); + const invalidEvent: SignEventPayload = { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }; const res = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { - event: { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }, + event: invalidEvent, }, }); expect(res.status()).toBe(400); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index e3cff89..ba914bb 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -5,135 +5,141 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; const state = loadState(); const { baseUrl, sessionId, adminUsername } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Admin – API keys', () => { test('GET /api/admin/api-keys returns list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('apiKeys'); + expect(Array.isArray(body.apiKeys)).toBe(true); + // At minimum the key created in global setup should be here + expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('apiKeys'); - expect(Array.isArray(body.apiKeys)).toBe(true); - // At minimum the key created in global setup should be here - expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); - await api.dispose(); }); test('POST /api/admin/api-keys creates a new key', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'temp-test-key' }, + await withApi(async (api) => { + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'temp-test-key' }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); - await api.dispose(); }); test('new API key can authenticate', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'auth-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - - // Use key to hit an auth-protected route (status is intentionally public). - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, + await withApi(async (api) => { + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'auth-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Use key to hit an auth-protected route. + const authRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); }); - expect(authRes.status()).toBe(200); - - await api.dispose(); }); test('revoked API key returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, + await withApi(async (api) => { + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(beforeRes.status()).toBe(200); + + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); + + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(afterRes.status()).toBe(401); }); - expect(beforeRes.status()).toBe(200); - - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); - - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(afterRes.status()).toBe(401); - - await api.dispose(); }); test('GET /api/admin/api-keys without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/api-keys'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/admin/api-keys'); + expect(res.status()).toBe(401); + }); }); }); test.describe('Admin – Users', () => { test('GET /api/admin/users returns user list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/users', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/users', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('users'); + expect(Array.isArray(body.users)).toBe(true); + expect(body.users.length).toBeGreaterThanOrEqual(1); + // Our admin user must be in the list + const found = body.users.some((u: { username: string }) => u.username === adminUsername); + expect(found).toBe(true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('users'); - expect(Array.isArray(body.users)).toBe(true); - expect(body.users.length).toBeGreaterThanOrEqual(1); - // Our admin user must be in the list - const found = body.users.some((u: { username: string }) => u.username === adminUsername); - expect(found).toBe(true); - await api.dispose(); }); test('GET /api/admin/whoami returns admin identity', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/whoami', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/admin/whoami', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('userId'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('userId'); - await api.dispose(); }); test('GET /api/admin/users without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/admin/users'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/admin/users'); + expect(res.status()).toBe(401); + }); }); }); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index d73ebfe..5e00398 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -8,8 +8,9 @@ import { test, expect } from '@playwright/test'; import { loginAs } from '../helpers.js'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, adminUsername, adminPassword } = state; test.describe('UI – Login page', () => { @@ -57,8 +58,10 @@ test.describe('UI – Authenticated app', () => { await configureTab.click(); // After clicking, the configure panel content should appear await page.waitForLoadState('networkidle'); - // Look for credential-related inputs or headings - const configContent = page.locator('input, textarea, [data-testid*="cred"]').first(); + // Scope to likely configure containers to avoid matching unrelated page inputs. + const configContent = page.locator( + '[role="tabpanel"] input, [role="tabpanel"] textarea, [role="tabpanel"] [data-testid*="cred"], [data-testid*="config"] input, [data-testid*="config"] textarea, [id*="config"] input, [id*="config"] textarea' + ).first(); await expect(configContent).toBeVisible({ timeout: 8_000 }); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 033695a..ba8ecfc 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -22,7 +22,7 @@ export interface SmokeTestState { * Zod schema for SmokeTestState persisted by global-setup. * Validates shape and types before returning from loadState. */ -const smokeTestStateSchema = z.object({ +const SMOKE_TEST_STATE_SCHEMA = z.object({ port: z.number().int().positive(), baseUrl: z.string().min(1, 'baseUrl must be non-empty'), tmpDir: z.string(), @@ -67,7 +67,7 @@ export function loadState(): SmokeTestState { if (!stateFile) return STUB; try { const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - const result = smokeTestStateSchema.safeParse(parsed); + const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); if (!result.success) { const issues = result.error.issues .map(i => `${i.path.join('.')}: ${i.message}`) diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 67abb95..590083c 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -77,8 +77,12 @@ describe('DB-mode /api/env behavior', () => { updateNode: () => {} }; - // Generate real FROSTR credentials so validateGroup/validateShare pass - const { generateKeysetWithSecret } = await import(root + 'node_modules/@frostr/igloo-core/dist/index.js'); + // Generate real FROSTR credentials so validateGroup/validateShare pass. + // Resolve from project root because this script runs from a temp directory. + const { createRequire } = await import('module'); + const requireFromRoot = createRequire(root + 'package.json'); + const iglooCorePath = requireFromRoot.resolve('@frostr/igloo-core'); + const { generateKeysetWithSecret } = await import(iglooCorePath); const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); const headers = new Headers({ diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index e1f2aa7..36755b7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -25,11 +25,11 @@ const ISOLATED_ENV_KEYS = [ 'AUTO_ADMIN_SECRET', 'SKIP_ADMIN_SECRET_VALIDATION', 'ENV_FILE_PATH', -]; +] as const; const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', -]; +] as const; function buildScriptEnv(overrides: Record): Record { const nextEnv: Record = {}; @@ -56,7 +56,11 @@ function buildScriptEnv(overrides: Record): Record = {}) { +/** + * Runs route code in an isolated Bun subprocess and returns the parsed @@RESULT@@ JSON payload. + * Uses T=any by default so callers without an explicit type can access result properties (e.g. out.status). + */ +export function runRouteScript(code: string, env: Record = {}): T { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); @@ -89,7 +93,14 @@ export function runRouteScript(code: string, env: Record = {}) { if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } - return JSON.parse(line.slice(line.indexOf(marker) + marker.length)); + const rawJson = line.slice(line.indexOf(marker) + marker.length); + try { + const parsed = JSON.parse(rawJson) as unknown; + return parsed as T; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`route script returned invalid JSON marker payload: ${detail}; raw="${rawJson}"; stdout="${stdout}"`); + } } finally { rmSync(tmpDir, { recursive: true, force: true }); } From 813aed6e17a8c1c1e322cd0b3b94aa1d6c9ba118 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 15:24:47 -0600 Subject: [PATCH 30/69] chore: harden e2e smoke flows and CI/env safeguards --- .github/workflows/ci.yml | 16 +- frontend/components/ui/peer-list.tsx | 10 +- llm/implementation/e2e-smoke-tests.md | 2 +- llm/implementation/umbrel-implementation.md | 2 +- src/routes/env.ts | 24 ++- src/routes/utils.test.ts | 17 ++ src/routes/utils.ts | 1 - tests/e2e/global-setup.ts | 14 +- tests/e2e/helpers.ts | 5 +- tests/e2e/specs/01-auth.e2e.ts | 3 +- tests/e2e/specs/02-status-peers.e2e.ts | 3 +- tests/e2e/specs/03-nip44-nip04.e2e.ts | 182 ++++++++++---------- tests/e2e/specs/04-sign.e2e.ts | 2 +- tests/e2e/specs/05-admin.e2e.ts | 68 +++++--- tests/e2e/specs/06-event-log.e2e.ts | 3 +- tests/e2e/specs/07-env.e2e.ts | 3 +- tests/routes/helpers/script-runner.ts | 2 +- 17 files changed, 211 insertions(+), 146 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3865c2d..2e863b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Type check run: bun run tsc --noEmit - - name: Run route tests + - name: Run unit tests run: bun run test:unit - name: Build frontend @@ -85,19 +85,25 @@ jobs: - name: Run security audit run: | audit_log="$(mktemp)" + last_exit=0 for attempt in 1 2 3; do if bun audit >"$audit_log" 2>&1; then cat "$audit_log" rm -f "$audit_log" exit 0 - fi - if [ "$attempt" -lt 3 ]; then - echo "bun audit failed (attempt $attempt), retrying..." - sleep 5 + else + audit_exit=$? + last_exit=$audit_exit + last_attempt=$attempt + if [ "$attempt" -lt 3 ]; then + echo "bun audit failed (attempt $attempt), retrying..." + sleep 5 + fi fi done audit_output="$(cat "$audit_log")" + echo "bun audit failed after ${last_attempt:-3} attempts with exit code ${last_exit}" if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then echo "bun audit failed after retries due to network/registry error: $audit_output" else diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 301fbe8..d233246 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -615,7 +615,15 @@ const PeerList: React.FC = ({ position="right" width="w-64" focusable - trigger={} + trigger={( + + )} content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

} diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index b4a4e8e..e2ab29b 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -80,7 +80,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l | `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | | `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | | `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | -| `FROSTR_SIGN_TIMEOUT` | `15000` | Allow 15 s for threshold signing | +| `FROSTR_SIGN_TIMEOUT` | `5000` | Cap setup/sign probe latency to 5 s per request | | `GROUP_CRED` | `''` | Clear any `.env` credential interference | | `SHARE_CRED` | `''` | Clear any `.env` credential interference | | `RELAYS` | `''` | Clear any `.env` relay interference | diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index a3e7955..7562f55 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -66,7 +66,7 @@ These values are set in the store compose and expected by the UI flow: ## Operational Notes - Healthcheck uses `curl http://localhost:8002/api/status` with retries and start period. -- The Umbrel store `docker-compose.yml` intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. +- `igloo-server/docker-compose.yml` (Umbrel store artifact) intentionally uses the `:umbrel-dev` tag pinned to a digest (e.g. `ghcr.io/frostr-org/igloo-server:umbrel-dev@sha256:...`). The tag stays `:umbrel-dev` on every release; only the digest is updated. This avoids Umbrel app-store tag-caching issues. - `packages/umbrel/igloo/docker-compose.yml` is a sideload/dev bundle and also points at `:umbrel-dev` but without a pinned digest. ## Update Checklist for Future Releases diff --git a/src/routes/env.ts b/src/routes/env.ts index d204929..b1d2572 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -148,6 +148,18 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged hasValidHeadlessApiKey(r) || hasValidHeadlessBasic(r) ); + const extractNonEmptyAdminSecret = (r: Request): string | undefined => { + const headerSecret = r.headers.get('X-Admin-Secret')?.trim(); + if (headerSecret && headerSecret.length > 0) return headerSecret; + + const authHeader = r.headers.get('Authorization'); + if (!authHeader) return undefined; + const bearerMatch = authHeader.match(/^Bearer\s+(.+)$/i); + if (!bearerMatch) return undefined; + const bearerToken = bearerMatch[1]?.trim(); + return bearerToken && bearerToken.length > 0 ? bearerToken : undefined; + }; + const isHeadlessReadAuthorized = (r: Request, a?: RequestAuth | null): boolean => { // If global auth is enabled and a session is present, allow; otherwise require API key or Basic. if (AUTH_CONFIG.ENABLED && a?.authenticated) return true; @@ -280,10 +292,8 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // - allow with valid ADMIN_SECRET (header: X-Admin-Secret or Bearer token), or // - allow when the authenticated DB user has role=admin. // validateAdminSecret() returns false when the header is missing; there is no bypass. - const authHeader = req.headers.get('Authorization'); - const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; - const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; - const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + const adminSecret = extractNonEmptyAdminSecret(req); + const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for environment modifications' }, @@ -665,10 +675,8 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged ); } if (!HEADLESS) { - const authHeader = req.headers.get('Authorization'); - const bearerToken = authHeader && /^Bearer\s+/i.test(authHeader) ? authHeader.replace(/^Bearer\s+/i, '') : undefined; - const adminSecret = req.headers.get('X-Admin-Secret') ?? bearerToken; - const isAdminSecret = await validateAdminSecret(adminSecret ?? undefined); + const adminSecret = extractNonEmptyAdminSecret(req); + const isAdminSecret = await validateAdminSecret(adminSecret); if (!isAdminSecret && !isRoleAdmin) { return Response.json( { error: 'Admin privileges required for deleting environment variables' }, diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 2f6f87f..5d51a93 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -56,6 +56,23 @@ describe('getValidRelays', () => { }); describe('normalizeRelayListForEcho', () => { + it('filters localhost relays when localhost relays are disallowed', () => { + const previous = process.env.ALLOW_LOCALHOST_RELAY; + process.env.ALLOW_LOCALHOST_RELAY = 'false'; + try { + expect( + normalizeRelayListForEcho([ + 'ws://127.0.0.1:18002', + 'ws://localhost:18002', + 'wss://relay.example.com' + ]) + ).toEqual(['wss://relay.example.com']); + } finally { + if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; + else process.env.ALLOW_LOCALHOST_RELAY = previous; + } + }); + it('keeps localhost relay in echo list when explicitly allowed', () => { const previous = process.env.ALLOW_LOCALHOST_RELAY; process.env.ALLOW_LOCALHOST_RELAY = 'true'; diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 67d70e8..0338bc6 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -816,7 +816,6 @@ export function normalizeRelayListForEcho(relays: any): string[] | undefined { .filter((r) => { try { const u = new URL(r); - if (u.protocol !== 'ws:' && u.protocol !== 'wss:') return false; if (!allowLocalhost && isLoopbackRelayHost(u.hostname)) return false; return true; } catch { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 2c8d311..f3101bf 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -211,10 +211,8 @@ export default async function globalSetup(_config: FullConfig): Promise { writeState(state); console.log('[setup] Generating FROSTR credentials...'); - const { generateKeysetWithSecret, decodeGroup } = await import('@frostr/igloo-core') as { - generateKeysetWithSecret: (t: number, n: number, sk: string) => { groupCredential: string; shareCredentials: string[] }; - decodeGroup: (g: string) => { group_pk: string; threshold: number; commits: unknown[] }; - }; + const iglooCore = await import('@frostr/igloo-core') as typeof import('@frostr/igloo-core'); + const { generateKeysetWithSecret, decodeGroup } = iglooCore; const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); const group = decodeGroup(groupCredential); @@ -238,7 +236,7 @@ export default async function globalSetup(_config: FullConfig): Promise { SKIP_STARTUP_ECHO: 'true', NODE_ENV: 'test', AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '15000', + FROSTR_SIGN_TIMEOUT: '5000', UI_EVENT_LOG_INCLUDE_PINGS: 'false', UPDATE_CHECK_DISABLED: 'true', ALLOW_LOCALHOST_RELAY: 'true', @@ -321,14 +319,14 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); const TEST_MSG = 'a'.repeat(64); let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { + for (let attempt = 1; attempt <= 4; attempt++) { if (cosignerProcess && cosignerProcess.exitCode !== null) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; throw new Error( `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` ); } - await sleep(3000); + await sleep(2000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { message: TEST_MSG }, @@ -343,7 +341,7 @@ export default async function globalSetup(_config: FullConfig): Promise { } if (!signOk) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within 15 s.\nCo-signer log:\n${cosLog}`); + throw new Error(`Co-signer did not become ready within probe window.\nCo-signer log:\n${cosLog}`); } console.log('[setup] Creating test API key...'); diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index 375bb63..ec80733 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -6,14 +6,11 @@ export async function loginAs(page: Page, username: string, password: string): P .locator('input[type="text"], input[id*="user"], input[name*="user"]') .first(); const passwordField = page.locator('input[type="password"]').first(); - const submitBtn = page - .locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")') - .first(); + const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); await usernameField.fill(username); await passwordField.fill(password); await submitBtn.click(); - await page.waitForLoadState('networkidle'); await expect( page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), 'login failed: expected dashboard tabs after submit' diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index e37a56b..176da8f 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -4,8 +4,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; test.describe('Auth – /api/auth', () => { diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index d5b4e2b..e864f47 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -4,8 +4,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Status – /api/status', () => { diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index 60b3353..10b7e39 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; const state = loadState(); @@ -13,71 +14,78 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; const PLAINTEXT = 'Hello from igloo smoke test!'; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + // ─── NIP-44 ────────────────────────────────────────────────────────────────── test.describe('NIP-44 – /api/nip44', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('encrypt returns ciphertext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + expect(typeof body.result).toBe('string'); + expect(body.result.length).toBeGreaterThan(0); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - expect(typeof body.result).toBe('string'); - expect(body.result.length).toBeGreaterThan(0); - await api.dispose(); }); test('encrypt then decrypt round-trips plaintext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - const encRes = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const encRes = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip44/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip44/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - - await api.dispose(); }); test('invalid peer_pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('missing content returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, + await withApi(async (api) => { + const res = await api.post('/api/nip44/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); @@ -85,66 +93,64 @@ test.describe('NIP-44 – /api/nip44', () => { test.describe('NIP-04 – /api/nip04', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('encrypt returns ciphertext with IV suffix', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('result'); + // NIP-04 ciphertext has the form ?iv= + expect(body.result).toMatch(/\?iv=/); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - // NIP-04 ciphertext has the form ?iv= - expect(body.result).toMatch(/\?iv=/); - await api.dispose(); }); test('encrypt then decrypt round-trips plaintext', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - - const encRes = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip04/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + await withApi(async (api) => { + const encRes = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, + }); + expect(encRes.status()).toBe(200); + const { result: ciphertext } = await encRes.json(); + + const decRes = await api.post('/api/nip04/decrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, + }); + expect(decRes.status()).toBe(200); + const { result: plaintext } = await decRes.json(); + expect(plaintext).toBe(PLAINTEXT); }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - - await api.dispose(); }); test('invalid peer_pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('missing content returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, + await withApi(async (api) => { + const res = await api.post('/api/nip04/encrypt', { + headers: { 'X-Session-ID': sessionId }, + data: { peer_pubkey: groupPubkeyHex }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 60b2300..76e28d1 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -1,7 +1,7 @@ /** * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. * - * sign timeout: 15 s (FROSTR_SIGN_TIMEOUT env set in global-setup). + * sign timeout: 5 s (FROSTR_SIGN_TIMEOUT env set in global-setup for smoke runs). * Test timeout overridden to 30 s to accommodate the signing round-trip. */ diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index ba914bb..68ed449 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -37,35 +37,57 @@ test.describe('Admin – API keys', () => { test('POST /api/admin/api-keys creates a new key', async () => { await withApi(async (api) => { - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'temp-test-key' }, - }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); + let createdKeyId: string | number | null = null; + try { + const res = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: `temp-test-key-${Date.now()}` }, + }); + expect(res.status()).toBe(201); + const body = await res.json(); + expect(body).toHaveProperty('apiKey'); + expect(body.apiKey).toHaveProperty('token'); + expect(typeof body.apiKey.token).toBe('string'); + expect(body.apiKey.token.length).toBeGreaterThan(20); + expect(body.apiKey).toHaveProperty('id'); + createdKeyId = body.apiKey.id; + } finally { + if (createdKeyId !== null) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, + }).catch(() => null); + } + } }); }); test('new API key can authenticate', async () => { await withApi(async (api) => { - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'auth-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); + let createdKeyId: string | number | null = null; + try { + // Create key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: `auth-test-key-${Date.now()}` }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + createdKeyId = apiKey.id; - // Use key to hit an auth-protected route. - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(authRes.status()).toBe(200); + // Use key to hit an auth-protected route. + const authRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey.token }, + }); + expect(authRes.status()).toBe(200); + } finally { + if (createdKeyId !== null) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, + }).catch(() => null); + } + } }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 3bcda19..4e2efd1 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -5,8 +5,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Event log – /api/event-log', () => { diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 61017e3..8879bfe 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -8,8 +8,9 @@ import { test, expect, request } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; test.describe('Env / credentials – /api/env', () => { diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 36755b7..32288eb 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -89,7 +89,7 @@ export function runRouteScript(code: string, env: Record l.includes(marker)); + const line = [...stdout.split('\n')].reverse().find(l => l.includes(marker)); if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } From 609544b8b3522ee64a7fde48754feec2596794f4 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 16:03:15 -0600 Subject: [PATCH 31/69] fix: tighten smoke test docs and cleanup guards --- llm/implementation/e2e-smoke-tests.md | 2 +- llm/implementation/umbrel-implementation.md | 2 +- src/routes/utils.test.ts | 65 ++++++++------------- tests/e2e/global-setup.ts | 23 ++++++-- tests/e2e/global-teardown.ts | 20 ++++++- tests/e2e/state.ts | 8 +-- 6 files changed, 65 insertions(+), 55 deletions(-) diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index e2ab29b..e75507f 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -266,7 +266,7 @@ This is by design — unauthenticated health checks and monitoring probes must b Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. -Unaffected: `GET /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). +Unaffected: `POST /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). For this reason: - The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index 7562f55..d2eb91d 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -72,5 +72,5 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). 2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). -3. Update `igloo-server-store/igloo-server/umbrel-app.yml` version and release notes. +3. Update `igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 5d51a93..80c9d6c 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,6 +1,17 @@ import { describe, expect, it } from 'bun:test'; import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; +function withEnv(key: string, value: string, fn: () => void): void { + const previous = process.env[key]; + process.env[key] = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } +} + describe('getValidRelays', () => { it('returns default relay when fallback is enabled and input is empty', () => { expect(getValidRelays()).toEqual(['wss://relay.primal.net']); @@ -22,44 +33,27 @@ describe('getValidRelays', () => { }); it('filters IPv6 localhost relay when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('filters localhost hostname relay when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); }); describe('normalizeRelayListForEcho', () => { it('filters localhost relays when localhost relays are disallowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'false'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', @@ -67,31 +61,18 @@ describe('normalizeRelayListForEcho', () => { 'wss://relay.example.com' ]) ).toEqual(['wss://relay.example.com']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('keeps localhost relay in echo list when explicitly allowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'true'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); it('keeps localhost hostname in echo list when explicitly allowed', () => { - const previous = process.env.ALLOW_LOCALHOST_RELAY; - process.env.ALLOW_LOCALHOST_RELAY = 'true'; - try { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); - } finally { - if (previous === undefined) delete process.env.ALLOW_LOCALHOST_RELAY; - else process.env.ALLOW_LOCALHOST_RELAY = previous; - } + }); }); }); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index f3101bf..b8e0d81 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -59,16 +59,16 @@ const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; -function sleep(ms: number) { +function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } -function writeState(state: SmokeTestState) { +function writeState(state: SmokeTestState): void { fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); process.env.SMOKE_STATE_FILE = STATE_FILE; } -function terminateProcess(proc: ChildProcess | null, label: string) { +function terminateProcess(proc: ChildProcess | null, label: string): void { if (!proc?.pid) return; try { process.kill(proc.pid, 'SIGTERM'); @@ -205,7 +205,22 @@ export default async function globalSetup(_config: FullConfig): Promise { process.env.SMOKE_STATE_FILE = STATE_FILE; try { - if (fs.existsSync(TMP_DIR)) fs.rmSync(TMP_DIR, { recursive: true, force: true }); + const resolvedTmp = path.resolve(TMP_DIR); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + + if (fs.existsSync(TMP_DIR)) { + if (isInsideTemp) { + fs.rmSync(TMP_DIR, { recursive: true, force: true }); + } else { + console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); + } + } fs.mkdirSync(TMP_DIR, { recursive: true }); fs.mkdirSync(DB_PATH, { recursive: true }); writeState(state); diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index bb4a4ad..21beb91 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -48,12 +48,26 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: SmokeTestState; + let state: SmokeTestState = { + port: 0, + baseUrl: '', + tmpDir: path.dirname(resolvedStateFile), + serverPid: 0, + cosignerPid: 0, + sessionId: '', + apiKey: null, + apiKeyId: null, + groupCredential: '', + shareCredentials: [], + groupPubkeyHex: '', + adminUsername: '', + adminPassword: '', + adminSecret: '', + }; try { state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; } catch { - console.warn('[teardown] Could not parse state file.'); - return; + console.warn('[teardown] Could not parse state file; using fallback cleanup state.'); } for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index ba8ecfc..a05698d 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -31,12 +31,12 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ sessionId: z.string().min(1, 'sessionId must be non-empty'), apiKey: z.string().nullable(), apiKeyId: z.string().nullable(), - groupCredential: z.string(), - shareCredentials: z.array(z.string()), + groupCredential: z.string().min(1, 'groupCredential must be non-empty'), + shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), groupPubkeyHex: z.string(), adminUsername: z.string(), - adminPassword: z.string(), - adminSecret: z.string(), + adminPassword: z.string().min(1, 'adminPassword must be non-empty'), + adminSecret: z.string().min(1, 'adminSecret must be non-empty'), }); const STUB: SmokeTestState = { From 657c79c1590e4ec0c4abbeee8e57d33ddbeb9672 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 16:43:31 -0600 Subject: [PATCH 32/69] fix: harden relay req validation and test safety --- src/class/relay.ts | 13 +++++++++++-- src/routes/utils.test.ts | 7 +++++++ tests/e2e/global-setup.ts | 2 +- tests/routes/env.db-mode.spec.ts | 6 +++++- tests/routes/helpers/script-runner.ts | 2 +- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/class/relay.ts b/src/class/relay.ts index f4dab29..2cf6914 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -155,10 +155,20 @@ class RelaySession { // Normalize nostr-tools 2.x format where filters are wrapped in an extra array: // New format: ["REQ", "sub_id", [{filter1}, {filter2}]] // NIP-01 format: ["REQ", "sub_id", {filter1}, {filter2}] + if (payload.length === 2 && Array.isArray(payload[1]) && payload[1].length === 0) { + this.log.info('ignoring REQ with empty filter array') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } if (payload.length === 2 && Array.isArray(payload[1])) { payload = [payload[0], ...payload[1]] } const [ id, ...filters ] = sub_schema.parse(payload) + if (filters.length === 0) { + this.log.info('ignoring REQ with no filters') + this.send(['NOTICE', '', 'REQ requires at least one filter']) + return + } return this._onreq(id, filters) case 'EVENT': const event = Nostr.parse_event(payload.at(0), this.relay.config.debug) @@ -191,8 +201,7 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.info('event failed validation (id=' + event.id.slice(0, 8) + ' kind=' + event.kind + ')') - this.log.debug('event details:', event) + this.log.info(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 80c9d6c..ffed2bd 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -49,6 +49,13 @@ describe('getValidRelays', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); + + it('keeps localhost relay when localhost relays are explicitly allowed', () => { + withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) + .toEqual(['ws://127.0.0.1:18002']); + }); + }); }); describe('normalizeRelayListForEcho', () => { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index b8e0d81..2e6e042 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -33,7 +33,7 @@ const SERVER_LOG = path.join(TMP_DIR, 'server.log'); const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); -const smokeDefaultsRaw = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); +const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); } diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 590083c..654f20d 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +const TEST_KEYSET_SECRET = + process.env.TEST_KEYSET_SECRET ?? + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { const script = ` @@ -83,7 +87,7 @@ describe('DB-mode /api/env behavior', () => { const requireFromRoot = createRequire(root + 'package.json'); const iglooCorePath = requireFromRoot.resolve('@frostr/igloo-core'); const { generateKeysetWithSecret } = await import(iglooCorePath); - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'); + const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, ${JSON.stringify(TEST_KEYSET_SECRET)}); const headers = new Headers({ 'Content-Type': 'application/json', diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 32288eb..1d23378 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -89,7 +89,7 @@ export function runRouteScript(code: string, env: Record l.includes(marker)); + const line = stdout.split('\n').reverse().find(l => l.includes(marker)); if (!line) { throw new Error(`route script missing result marker: ${stdout}`); } From 959edf4a101038f3e39767aa11bff514190b1ed7 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 17:04:38 -0600 Subject: [PATCH 33/69] fix: harden e2e cleanup, docs, and context handling --- .gitignore | 1 + .../node-lifecycle-implementation.md | 2 +- llm/implementation/umbrel-implementation.md | 4 +- tests/e2e/cosigner.mjs | 6 +- tests/e2e/global-teardown.ts | 16 ++ tests/e2e/specs/03-nip44-nip04.e2e.ts | 3 +- tests/e2e/specs/04-sign.e2e.ts | 166 ++++++++++-------- tests/e2e/specs/06-event-log.e2e.ts | 1 + 8 files changed, 115 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index eda8834..9945c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,7 @@ test-*.sh debug-*.js verify-*.md test-results/ +playwright-report/ # LLM files .claude diff --git a/llm/implementation/node-lifecycle-implementation.md b/llm/implementation/node-lifecycle-implementation.md index 661c492..b605e7a 100644 --- a/llm/implementation/node-lifecycle-implementation.md +++ b/llm/implementation/node-lifecycle-implementation.md @@ -61,7 +61,7 @@ DB user updates (`/api/user/credentials`): - The node client request timeout is adjusted to `getOpTimeoutMs()` (bounded) when possible. - The node is wrapped in an instrumented proxy to track publish metrics and optionally swallow benign publish errors. - `NODE_PUBLISH_METRICS=false` disables instrumentation. -- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. +- `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is authoritative; `RELAY_ALLOW_BENIGN_SWALLOW` is a backward-compatibility fallback consulted only when `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset (`NODE_ALLOW_BENIGN_PUBLISH_SWALLOW ?? RELAY_ALLOW_BENIGN_SWALLOW`). Any explicit value on `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` (including `true` or `false`) takes precedence. To force publish errors to surface, set `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW=false`; if `NODE_ALLOW_BENIGN_PUBLISH_SWALLOW` is unset, set `RELAY_ALLOW_BENIGN_SWALLOW=false`. - Initial connectivity check runs after optional `INITIAL_CONNECTIVITY_DELAY` to avoid startup races. ## Monitoring and Recovery diff --git a/llm/implementation/umbrel-implementation.md b/llm/implementation/umbrel-implementation.md index d2eb91d..36d643b 100644 --- a/llm/implementation/umbrel-implementation.md +++ b/llm/implementation/umbrel-implementation.md @@ -71,6 +71,6 @@ These values are set in the store compose and expected by the UI flow: ## Update Checklist for Future Releases 1. Build and push the new Umbrel image (`:umbrel-` and `:umbrel-latest`). -2. Update the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). -3. Update `igloo-server/umbrel-app.yml` version and release notes. +2. Bump the digest in `igloo-server/docker-compose.yml` (keep the `:umbrel-dev` tag; only the `@sha256:...` digest changes). +3. Revise `igloo-server/umbrel-app.yml` version and release notes. 4. Refresh gallery assets if the UI has changed. diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 60eb439..6b11c32 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -79,12 +79,14 @@ try { } } catch (err) { - console.error('[cosigner] Failed to start:', err.message ?? err); + console.error('[cosigner] Failed to start:', err instanceof Error ? err.message : String(err)); process.exit(2); } const shutdown = () => { - try { node?.close?.(); } catch {} + try { node?.close?.(); } catch (e) { + console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); + } process.exit(0); }; process.on('SIGTERM', shutdown); diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 21beb91..ff53eb1 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -9,6 +9,8 @@ import path from 'path'; import type { FullConfig } from '@playwright/test'; import type { SmokeTestState } from './state.js'; +const MAX_STATE_AGE_MS = 10 * 60 * 1000; + function findLatestStateFile(): string | null { const tmpRoot = os.tmpdir(); let latestFile: string | null = null; @@ -48,6 +50,20 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } + try { + const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; + if (ageMs > MAX_STATE_AGE_MS) { + console.warn( + `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + + 'skipping process kill and temp cleanup to avoid affecting unrelated runs.' + ); + return; + } + } catch (error) { + console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); + return; + } + let state: SmokeTestState = { port: 0, baseUrl: '', diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts index 10b7e39..4657b49 100644 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ b/tests/e2e/specs/03-nip44-nip04.e2e.ts @@ -8,8 +8,9 @@ import { test, expect, request } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, groupPubkeyHex } = state; const PLAINTEXT = 'Hello from igloo smoke test!'; diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 76e28d1..5cfc047 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -6,6 +6,7 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; @@ -24,118 +25,127 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Sign – /api/sign', () => { // Explicit per-suite timeout for signing flows; global timeout is also 30_000. test.setTimeout(30_000); test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - data: { message: EVENT_ID_A }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('returns 400 for invalid (non-hex) message', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'not-hex' }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'not-hex' }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('returns 400 for message shorter than 32 bytes', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'deadbeef' }, // only 4 bytes + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'deadbeef' }, // only 4 bytes + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('returns 400 for missing body', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: {}, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: {}, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('signs a 32-byte hex message and returns signature', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: EVENT_ID_A }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: EVENT_ID_A }, + }); + expect(res.status()).toBe(200); + + const body = await res.json(); + expect(body).toHaveProperty('id', EVENT_ID_A); + expect(body).toHaveProperty('signature'); + expect(typeof body.signature).toBe('string'); + // Schnorr signature = 64 bytes = 128 hex chars + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id', EVENT_ID_A); - expect(body).toHaveProperty('signature'); - expect(typeof body.signature).toBe('string'); - // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); }); test('signs a full event object and returns signature', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - // Use the group pubkey as the event author pubkey - const event: SignEventPayload = { - pubkey: groupPubkeyHex, - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'igloo smoke test', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { event }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + // Use the group pubkey as the event author pubkey + const event: SignEventPayload = { + pubkey: groupPubkeyHex, + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'igloo smoke test', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { event }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('id'); - expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); + const body = await res.json(); + expect(body).toHaveProperty('id'); + expect(body).toHaveProperty('signature'); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + }); }); test('signing works with API key auth', async () => { test.skip(!state.apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/sign', { - headers: { 'X-API-Key': state.apiKey! }, - data: { message: EVENT_ID_B }, + await withApi(async (api) => { + const res = await api.post('/api/sign', { + headers: { 'X-API-Key': state.apiKey! }, + data: { message: EVENT_ID_B }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); - await api.dispose(); }); test('event with invalid pubkey returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const invalidEvent: SignEventPayload = { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { - event: invalidEvent, - }, + await withApi(async (api) => { + const invalidEvent: SignEventPayload = { + pubkey: 'not-64-hex', + kind: 1, + created_at: Math.floor(Date.now() / 1000), + content: 'bad', + tags: [], + }; + const res = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { + event: invalidEvent, + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 4e2efd1..0585383 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -71,6 +71,7 @@ test.describe('Event log – /api/event-log', () => { const text = await res.text(); // Each non-empty line must be valid JSON const lines = text.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThan(0); for (const line of lines) { expect(() => JSON.parse(line)).not.toThrow(); } From 6d73a9894c63253728eff3f3c9ba6dab7892d3d7 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Tue, 24 Feb 2026 19:51:32 -0600 Subject: [PATCH 34/69] fix: harden e2e smoke setup and route test safety --- bun.lock | 4 +- frontend/components/ui/peer-list.tsx | 21 ++- package.json | 1 - src/class/relay.test.ts | 81 ++++++++++++ src/routes/env.ts | 17 +-- src/routes/utils.test.ts | 44 ++++--- src/routes/utils.ts | 42 +++++- tests/e2e/global-setup.ts | 71 ++++++++-- tests/e2e/global-teardown.ts | 146 +++++++++++---------- tests/e2e/helpers.ts | 2 +- tests/e2e/smoke-test-defaults.json | 5 +- tests/e2e/specs/01-auth.e2e.ts | 138 ++++++++++--------- tests/e2e/specs/02-status-peers.e2e.ts | 128 +++++++++--------- tests/e2e/specs/05-admin.e2e.ts | 2 + tests/e2e/specs/06-event-log.e2e.ts | 112 +++++++++------- tests/e2e/specs/07-env.e2e.ts | 124 +++++++++-------- tests/e2e/specs/08-ui.e2e.ts | 6 +- tests/e2e/state.ts | 2 +- tests/routes/env.db-mode.spec.ts | 23 +++- tests/routes/helpers/script-runner.spec.ts | 67 ++++++++++ tests/routes/helpers/script-runner.ts | 45 ++++--- 21 files changed, 711 insertions(+), 370 deletions(-) create mode 100644 src/class/relay.test.ts create mode 100644 tests/routes/helpers/script-runner.spec.ts diff --git a/bun.lock b/bun.lock index c9f0e76..ce4e1de 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "react-dom": "^18.3.1", "tailwind-merge": "^3.3.1", "yaml": "^2.8.1", + "zod": "^3.25.76", }, "devDependencies": { "@playwright/test": "^1.58.2", @@ -31,7 +32,6 @@ "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", @@ -284,8 +284,6 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], - "@types/yaml": ["@types/yaml@1.9.7", "", { "dependencies": { "yaml": "*" } }, "sha512-8WMXRDD1D+wCohjfslHDgICd2JtMATZU8CkhH8LVJqcJs6dyYj5TGptzP8wApbmEullGBSsCEzzap73DQ1HJaA=="], - "@vbyte/buff": ["@vbyte/buff@1.0.2", "", {}, "sha512-h/3CU+9H6fWZzAfM9/ar9FpQdRfupYyL5ug3fJ9hofzSuWytojVUAT32pnBtTIatowGVburZ/qj0xSpTXWA8qA=="], "@vbyte/micro-lib": ["@vbyte/micro-lib@1.1.2", "", { "dependencies": { "@noble/curves": "^1.9.6", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", "@vbyte/buff": "^1.0.2", "zod": "4.0.14" } }, "sha512-SYOTaaY4zAxuIZPdzVQAyyxAi83ZGt+LtXsjyrUjYsQuWzipLsTIF0h5fyLccOn7Mi+4tY9fPUrvMZNQJDijQg=="], diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index d233246..192a01b 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -307,7 +307,7 @@ const PeerList: React.FC = ({ // Perform initial ping sweep setIsInitialPingSweep(true); try { - await fetch('/api/peers/ping', { + const pingResponse = await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -315,9 +315,13 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - - // Refresh peer list after ping sweep - await fetchPeers(); + if (!pingResponse.ok) { + const detail = await pingResponse.text().catch(() => '(unreadable)'); + console.debug(`[PeerList] Initial ping sweep failed (${pingResponse.status}): ${detail}`); + } else { + // Refresh peer list after ping sweep + await fetchPeers(); + } } catch (pingError) { console.debug('Initial ping sweep failed:', pingError); // Don't set error state for ping failures @@ -525,7 +529,7 @@ const PeerList: React.FC = ({ return; } try { - await fetch('/api/peers/ping', { + const response = await fetch('/api/peers/ping', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -533,7 +537,12 @@ const PeerList: React.FC = ({ }, body: JSON.stringify({ target: 'all' }) }); - + if (!response.ok) { + const detail = await response.text().catch(() => '(unreadable)'); + console.warn(`[PeerList] Ping all failed (${response.status}): ${detail}`); + return; + } + // Refresh peer list after pinging all await fetchPeers(); } catch (error) { diff --git a/package.json b/package.json index 4c98c50..51368fb 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "@types/node": "^22.18.12", "@types/react": "^18.3.26", "@types/react-dom": "^18.3.7", - "@types/yaml": "^1.9.7", "ajv": "^8.18.0", "bun-types": "^1.3.1", "concurrently": "^9.2.1", diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts new file mode 100644 index 0000000..6d716cb --- /dev/null +++ b/src/class/relay.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'bun:test'; +import { NostrRelay } from './relay.js'; + +type FakeSocket = { + data: any; + sent: string[]; + closed: boolean; + send: (message: string) => void; + close: () => void; +}; + +function createFakeSocket(): FakeSocket { + return { + data: null, + sent: [], + closed: false, + send(message: string) { + this.sent.push(message); + }, + close() { + this.closed = true; + }, + }; +} + +function decodeSent(socket: FakeSocket): unknown[][] { + return socket.sent.map((message) => JSON.parse(message) as unknown[]); +} + +describe('NostrRelay REQ handling', () => { + it('normalizes wrapped nostr-tools REQ filter arrays into subscriptions', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-1'); + expect(sub?.filters).toHaveLength(1); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-1']); + }); + + it('rejects REQ with an empty wrapped filter array', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-empty', []])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + + it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const handler = relay.handler(); + + handler.open?.(socket as any); + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + handler.message?.(socket as any, JSON.stringify(['CLOSE', 'sub-close'])); + expect(relay.subs.size).toBe(0); + + handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); + expect(relay.subs.size).toBe(1); + + handler.close?.(socket as any, 1000, 'test'); + expect(relay.subs.size).toBe(0); + expect(socket.closed).toBe(true); + }); +}); diff --git a/src/routes/env.ts b/src/routes/env.ts index b1d2572..a34899f 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -92,9 +92,12 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // but we keep the broader classification for clarity. const isWrite = req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE'; // Resolve authenticated DB user id (database mode only) - const authenticatedNumericUserId = (!HEADLESS && auth?.authenticated && ( - typeof auth.userId === 'number' || (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) - )) ? BigInt(auth!.userId as any) : null; + const authenticatedNumericUserId = (() => { + if (HEADLESS || !auth?.authenticated) return null; + if (typeof auth.userId === 'number') return BigInt(auth.userId); + if (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) return BigInt(auth.userId); + return null; + })(); const isRoleAdmin = await (async () => { try { if (authenticatedNumericUserId === null) return false; @@ -351,14 +354,6 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged return Response.json({ success: false, message: 'Failed to update .env file' }, { status: 500, headers }); } - // Headless writes must be authorized by API key or Basic (sessions are not sufficient) - if (HEADLESS && !hasHeadlessWriteAuthorization(req)) { - return Response.json( - { error: 'Authentication required' }, - { status: 401, headers } - ); - } - let body; try { body = await parseJsonRequestBody(req); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index ffed2bd..a7b4d6a 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from 'bun:test'; import { getValidRelays, normalizeRelayListForEcho } from './utils.js'; -function withEnv(key: string, value: string, fn: () => void): void { +async function withEnv(key: string, value: string, fn: () => Promise | T): Promise { const previous = process.env[key]; process.env[key] = value; try { - fn(); + return await fn(); } finally { if (previous === undefined) delete process.env[key]; else process.env[key] = previous; @@ -32,26 +32,38 @@ describe('getValidRelays', () => { expect(getValidRelays('["not-a-relay","ftp://example.com"]', { fallbackToDefault: false })).toEqual([]); }); - it('filters IPv6 localhost relay when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters IPv6 localhost relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::1]:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters 127.0.0.0/8 localhost relay range when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://127.0.0.2:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('filters localhost hostname relay when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters localhost hostname relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://localhost:18002"]', { fallbackToDefault: false })).toEqual([]); }); }); - it('keeps localhost relay when localhost relays are explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters IPv4-mapped IPv6 hex relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) .toEqual(['ws://127.0.0.1:18002']); }); @@ -59,8 +71,8 @@ describe('getValidRelays', () => { }); describe('normalizeRelayListForEcho', () => { - it('filters localhost relays when localhost relays are disallowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + it('filters localhost relays when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', @@ -71,14 +83,14 @@ describe('normalizeRelayListForEcho', () => { }); }); - it('keeps localhost relay in echo list when explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('keeps localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://127.0.0.1:18002'])).toEqual(['ws://127.0.0.1:18002']); }); }); - it('keeps localhost hostname in echo list when explicitly allowed', () => { - withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + it('keeps localhost hostname in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 0338bc6..a54206b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -32,13 +32,51 @@ export function binaryToHex(data: Uint8Array | Buffer): string | null { return hex.toLowerCase(); } +function isValidIpv4Address(hostname: string): boolean { + const octets = hostname.split('.'); + if (octets.length !== 4) return false; + return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); +} + +function decodeMappedIpv4(segment: string): string | null { + if (isValidIpv4Address(segment)) return segment; + const mappedHex = segment.match(/^([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i); + if (!mappedHex) return null; + const hi = Number.parseInt(mappedHex[1], 16); + const lo = Number.parseInt(mappedHex[2], 16); + const decoded = `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`; + return isValidIpv4Address(decoded) ? decoded : null; +} + +function extractIpv4MappedIpv6(hostname: string): string | null { + const mappedPrefixes = [ + /^::ffff:(.+)$/i, + /^::ffff:0:(.+)$/i, + /^0:0:0:0:0:ffff:(.+)$/i, + /^0:0:0:0:ffff:0:(.+)$/i, + ]; + for (const pattern of mappedPrefixes) { + const match = hostname.match(pattern); + if (!match) continue; + const decoded = decodeMappedIpv4(match[1]); + if (decoded) return decoded; + } + return null; +} + function isLoopbackRelayHost(hostname: string): boolean { - const normalized = hostname.replace(/^\[(.*)\]$/, '$1'); + let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); if (normalized === 'localhost' || normalized === '::1') return true; + + const mappedIpv4 = extractIpv4MappedIpv6(normalized); + if (mappedIpv4) { + normalized = mappedIpv4; + } + const octets = normalized.split('.'); if (octets.length !== 4) return false; if (octets[0] !== '127') return false; - return octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255); + return isValidIpv4Address(normalized); } // Helper function to get valid relay URLs diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 2e6e042..7d58bd6 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -37,27 +37,76 @@ const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); } -const requiredKeys = ['testNsecHex', 'adminSecret', 'adminUsername', 'adminPassword'] as const; const raw = smokeDefaultsRaw as Record; -const missing = requiredKeys.filter(k => raw[k] == null || typeof raw[k] !== 'string'); -if (missing.length > 0) { +if (typeof raw.testNsecHex !== 'string' || raw.testNsecHex.trim().length === 0) { throw new Error( - `smoke-test-defaults.json is missing required string properties: ${missing.join(', ')}. ` + - `Expected: ${requiredKeys.join(', ')}`, + 'smoke-test-defaults.json is missing required non-empty string property: testNsecHex.', ); } const smokeDefaults = raw as { testNsecHex: string; +}; + +function loadOptionalLocalSmokeCredentials(): Partial<{ adminSecret: string; adminUsername: string; adminPassword: string; -}; +}> { + const localFixturePath = + process.env.SMOKE_LOCAL_FIXTURE_PATH?.trim() || + path.resolve('tests/e2e/smoke-test.local.json'); + if (!fs.existsSync(localFixturePath)) { + return {}; + } + try { + const localRaw: unknown = JSON.parse(fs.readFileSync(localFixturePath, 'utf8')); + if (typeof localRaw !== 'object' || localRaw === null) { + throw new Error('expected JSON object'); + } + const fixture = localRaw as Record; + return { + adminSecret: typeof fixture.adminSecret === 'string' && fixture.adminSecret.trim().length > 0 + ? fixture.adminSecret + : undefined, + adminUsername: typeof fixture.adminUsername === 'string' && fixture.adminUsername.trim().length > 0 + ? fixture.adminUsername + : undefined, + adminPassword: typeof fixture.adminPassword === 'string' && fixture.adminPassword.trim().length > 0 + ? fixture.adminPassword + : undefined, + }; + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to parse local smoke fixture at ${localFixturePath}: ${detail}`); + } +} + +const localSmokeCredentials = loadOptionalLocalSmokeCredentials(); + +function requireNonEmptyString(value: string | undefined, errorMessage: string): string { + if (!value || value.trim().length === 0) { + throw new Error(errorMessage); + } + return value; +} // Defaults come from fixture for local CI; callers can still override via environment. const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; -const ADMIN_SECRET = process.env.ADMIN_SECRET ?? smokeDefaults.adminSecret; -const ADMIN_USERNAME = process.env.ADMIN_USERNAME ?? smokeDefaults.adminUsername; -const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? smokeDefaults.adminPassword; +const MISSING_SMOKE_CREDS_MESSAGE = + 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + + 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; +const ADMIN_SECRET = requireNonEmptyString( + process.env.SMOKE_ADMIN_SECRET ?? process.env.ADMIN_SECRET ?? localSmokeCredentials.adminSecret, + MISSING_SMOKE_CREDS_MESSAGE +); +const ADMIN_USERNAME = requireNonEmptyString( + process.env.SMOKE_ADMIN_USERNAME ?? process.env.ADMIN_USERNAME ?? localSmokeCredentials.adminUsername, + MISSING_SMOKE_CREDS_MESSAGE +); +const ADMIN_PASSWORD = requireNonEmptyString( + process.env.SMOKE_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD ?? localSmokeCredentials.adminPassword, + MISSING_SMOKE_CREDS_MESSAGE +); function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); @@ -334,14 +383,14 @@ export default async function globalSetup(_config: FullConfig): Promise { console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); const TEST_MSG = 'a'.repeat(64); let signOk = false; - for (let attempt = 1; attempt <= 4; attempt++) { + for (let attempt = 1; attempt <= 5; attempt++) { if (cosignerProcess && cosignerProcess.exitCode !== null) { const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; throw new Error( `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` ); } - await sleep(2000); + await sleep(3000); const sr = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, data: { message: TEST_MSG }, diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index ff53eb1..d5ea286 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -11,43 +11,74 @@ import type { SmokeTestState } from './state.js'; const MAX_STATE_AGE_MS = 10 * 60 * 1000; -function findLatestStateFile(): string | null { - const tmpRoot = os.tmpdir(); - let latestFile: string | null = null; - let latestMtime = 0; +function parsePositivePid(raw: unknown, fieldName: string): number | null { + if (raw == null || raw === 0) return null; + if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) { + console.warn(`[teardown] Invalid ${fieldName}; expected positive integer PID, got:`, raw); + return null; + } + return raw; +} + +function isProcessRunning(pid: number): boolean { try { - for (const entry of fs.readdirSync(tmpRoot, { withFileTypes: true })) { - if (!entry.isDirectory() || !entry.name.startsWith('igloo-smoke-test')) continue; - const candidate = path.join(tmpRoot, entry.name, 'state.json'); - if (!fs.existsSync(candidate)) continue; - try { - const mtime = fs.statSync(candidate).mtimeMs; - if (mtime > latestMtime) { - latestMtime = mtime; - latestFile = candidate; - } - } catch { - // Ignore transient stat/read errors when scanning tmp entries. - } + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM') return true; + return false; + } +} + +function resolveSafeTmpDir(rawTmpDir: unknown): string | null { + if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { + console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); + return null; + } + const resolvedTmp = path.resolve(rawTmpDir); + const tempRoot = path.resolve(os.tmpdir()); + const relToTempRoot = path.relative(tempRoot, resolvedTmp); + const isInsideTemp = + relToTempRoot.length > 0 && + relToTempRoot !== '.' && + !relToTempRoot.startsWith('..') && + !path.isAbsolute(relToTempRoot); + if (!isInsideTemp) { + console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); + return null; + } + if (!path.basename(resolvedTmp).startsWith('igloo-smoke-test-')) { + console.warn('[teardown] Refusing to remove unexpected temp dir name:', resolvedTmp); + return null; + } + try { + if (!fs.existsSync(resolvedTmp)) { + console.warn('[teardown] tmpDir does not exist; skipping temp cleanup:', resolvedTmp); + return null; } - } catch { + if (!fs.statSync(resolvedTmp).isDirectory()) { + console.warn('[teardown] tmpDir is not a directory; skipping temp cleanup:', resolvedTmp); + return null; + } + } catch (error) { + console.warn('[teardown] Could not validate tmpDir; skipping temp cleanup:', error); return null; } - - return latestFile; + return resolvedTmp; } export default async function globalTeardown(_config: FullConfig): Promise { const stateFile = process.env.SMOKE_STATE_FILE; - const resolvedStateFile = - stateFile && fs.existsSync(stateFile) - ? stateFile - : findLatestStateFile(); - console.log('[teardown] Resolved state file:', resolvedStateFile ?? '(none)'); + if (!stateFile || stateFile.trim().length === 0) { + throw new Error('[teardown] SMOKE_STATE_FILE is required; refusing to guess a state file.'); + } - if (!resolvedStateFile || !fs.existsSync(resolvedStateFile)) { - console.warn('[teardown] No state file found – nothing to clean up.'); - return; + const resolvedStateFile = path.resolve(stateFile); + console.log('[teardown] Resolved state file:', resolvedStateFile); + + if (!fs.existsSync(resolvedStateFile)) { + throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); } try { @@ -64,61 +95,44 @@ export default async function globalTeardown(_config: FullConfig): Promise return; } - let state: SmokeTestState = { - port: 0, - baseUrl: '', - tmpDir: path.dirname(resolvedStateFile), - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: '', - adminPassword: '', - adminSecret: '', - }; + let parsedState: Partial; try { - state = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as SmokeTestState; - } catch { - console.warn('[teardown] Could not parse state file; using fallback cleanup state.'); + parsedState = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as Partial; + } catch (error) { + console.warn('[teardown] Could not parse state file; skipping cleanup for safety:', error); + return; } - for (const [label, pid] of [['co-signer', state.cosignerPid], ['server', state.serverPid]] as const) { + const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); + const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); + const safeTmpDir = resolveSafeTmpDir(parsedState.tmpDir); + + for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { if (!pid) continue; + if (!isProcessRunning(pid)) { + console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); + continue; + } try { process.kill(pid, 'SIGTERM'); console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); } catch (err: unknown) { - // ESRCH = process already gone, which is fine if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); } } } - // Brief pause to let processes flush logs await new Promise(r => setTimeout(r, 500)); - const tmpDir = state.tmpDir || path.dirname(resolvedStateFile); - try { - const resolvedTmp = path.resolve(tmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); + if (!safeTmpDir) { + console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); + return; + } - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - } else { - fs.rmSync(resolvedTmp, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', resolvedTmp); - } + try { + fs.rmSync(safeTmpDir, { recursive: true, force: true }); + console.log('[teardown] Removed temp dir', safeTmpDir); } catch (err) { console.warn('[teardown] Could not remove temp dir:', err); } diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts index ec80733..c0a8cd7 100644 --- a/tests/e2e/helpers.ts +++ b/tests/e2e/helpers.ts @@ -3,7 +3,7 @@ import type { Page } from '@playwright/test'; export async function loginAs(page: Page, username: string, password: string): Promise { const usernameField = page - .locator('input[type="text"], input[id*="user"], input[name*="user"]') + .locator('input[autocomplete="username"], input[id*="user" i], input[name*="user" i], input[placeholder*="user" i]') .first(); const passwordField = page.locator('input[type="password"]').first(); const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json index 298a223..2c2419e 100644 --- a/tests/e2e/smoke-test-defaults.json +++ b/tests/e2e/smoke-test-defaults.json @@ -1,6 +1,3 @@ { - "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef", - "adminSecret": "SmokeTestAdmin1", - "adminUsername": "testadmin", - "adminPassword": "T3stPass@9" + "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" } diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts index 176da8f..d10a25d 100644 --- a/tests/e2e/specs/01-auth.e2e.ts +++ b/tests/e2e/specs/01-auth.e2e.ts @@ -3,117 +3,127 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Auth – /api/auth', () => { test('GET /api/auth/status returns enabled methods', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/auth/status'); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(typeof body).toBe('object'); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/auth/status'); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(typeof body).toBe('object'); + }); }); test('POST /api/auth/login – valid credentials return sessionId', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('sessionId'); + expect(typeof body.sessionId).toBe('string'); + expect(body.sessionId.length).toBeGreaterThan(8); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('sessionId'); - expect(typeof body.sessionId).toBe('string'); - expect(body.sessionId.length).toBeGreaterThan(8); - await api.dispose(); }); test('POST /api/auth/login – wrong password returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: 'WrongPass@1' }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: adminUsername, password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('POST /api/auth/login – unknown user returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/auth/login', { - data: { username: 'nobody', password: 'WrongPass@1' }, + await withApi(async (api) => { + const res = await api.post('/api/auth/login', { + data: { username: 'nobody', password: 'WrongPass@1' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('GET /api/peers – no auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + }); }); test('GET /api/status – valid session returns 200', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { test.skip(!apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey! }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-API-Key': apiKey! }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { test.skip(!apiKey, 'No API key available'); - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { Authorization: `Bearer ${apiKey!}` }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { Authorization: `Bearer ${apiKey!}` }, + }); + expect(res.status()).toBe(200); }); - expect(res.status()).toBe(200); - await api.dispose(); }); test('GET /api/peers – invalid API key returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers', { - headers: { 'X-API-Key': 'totally-invalid-key' }, + await withApi(async (api) => { + const res = await api.get('/api/peers', { + headers: { 'X-API-Key': 'totally-invalid-key' }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); test('POST /api/auth/logout – returns 200 and clears session', async () => { // Log in fresh so we don't burn the shared session - const api = await request.newContext({ baseURL: baseUrl }); - const loginRes = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(loginRes.status()).toBe(200); - const { sessionId: tempSession } = await loginRes.json(); + await withApi(async (api) => { + const loginRes = await api.post('/api/auth/login', { + data: { username: adminUsername, password: adminPassword }, + }); + expect(loginRes.status()).toBe(200); + const { sessionId: tempSession } = await loginRes.json(); - const logoutRes = await api.post('/api/auth/logout', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(logoutRes.status()).toBe(200); + const logoutRes = await api.post('/api/auth/logout', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(logoutRes.status()).toBe(200); - // The session should now be invalid - const afterRes = await api.get('/api/peers', { - headers: { 'X-Session-ID': tempSession }, + // The session should now be invalid + const afterRes = await api.get('/api/peers', { + headers: { 'X-Session-ID': tempSession }, + }); + expect(afterRes.status()).toBe(401); }); - expect(afterRes.status()).toBe(401); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index e864f47..cddaefb 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -3,98 +3,108 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Status – /api/status', () => { test('GET /api/status is publicly accessible without auth', async () => { // /api/status intentionally allows unauthenticated health checks - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status'); - expect(res.status()).toBe(200); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/status'); + expect(res.status()).toBe(200); + }); }); test('GET /api/status returns 200 with node info', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.serverRunning).toBe(true); - expect(body.nodeActive).toBe(true); - expect(body).toHaveProperty('health'); - expect(body).toHaveProperty('relayCount'); - expect(body).toHaveProperty('timestamp'); - await api.dispose(); + const body = await res.json(); + expect(body.serverRunning).toBe(true); + expect(body.nodeActive).toBe(true); + expect(body).toHaveProperty('health'); + expect(body).toHaveProperty('relayCount'); + expect(body).toHaveProperty('timestamp'); + }); }); test('GET /api/status has valid health object', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/status', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.health).toHaveProperty('isConnected'); + expect(typeof body.health.isConnected).toBe('boolean'); + expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.health).toHaveProperty('isConnected'); - expect(typeof body.health.isConnected).toBe('boolean'); - expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); - await api.dispose(); }); }); test.describe('Peers – /api/peers', () => { test('GET /api/peers returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/peers'); + expect(res.status()).toBe(401); + }); }); test('GET /api/peers returns peer list', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/peers', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('peers'); - expect(Array.isArray(body.peers)).toBe(true); - // 2-of-2 keyset: 1 remote peer (self filtered out) - expect(body.peers.length).toBeGreaterThanOrEqual(1); - expect(typeof body.total).toBe('number'); - expect(typeof body.online).toBe('number'); - await api.dispose(); + const body = await res.json(); + expect(body).toHaveProperty('peers'); + expect(Array.isArray(body.peers)).toBe(true); + // 2-of-2 keyset: 1 remote peer (self filtered out) + expect(body.peers.length).toBeGreaterThanOrEqual(1); + expect(typeof body.total).toBe('number'); + expect(typeof body.online).toBe('number'); + }); }); test('GET /api/peers/group returns group pubkey', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers/group', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/peers/group', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(typeof body.threshold).toBe('number'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(state.groupPubkeyHex); - expect(typeof body.threshold).toBe('number'); - await api.dispose(); }); test('GET /api/peers/self returns own share pubkey', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/peers/self', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/peers/self', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('pubkey'); + expect(typeof body.pubkey).toBe('string'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(typeof body.pubkey).toBe('string'); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 68ed449..f801ea4 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -154,7 +154,9 @@ test.describe('Admin – Users', () => { }); expect(res.status()).toBe(200); const body = await res.json(); + expect(body).toHaveProperty('admin', true); expect(body).toHaveProperty('userId'); + expect(body.userId).not.toBeNull(); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 0585383..2f15fc3 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -4,84 +4,94 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Event log – /api/event-log', () => { test('returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/event-log'); + expect(res.status()).toBe(401); + }); }); test('GET /api/event-log returns entries array', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty('entries'); + expect(Array.isArray(body.entries)).toBe(true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('entries'); - expect(Array.isArray(body.entries)).toBe(true); - await api.dispose(); }); test('entries have expected shape', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(Array.isArray(body.entries)).toBe(true); + expect(body.entries.length).toBeGreaterThan(0); + const entry = body.entries[0]; + expect(entry).toHaveProperty('type'); + expect(entry).toHaveProperty('message'); + expect(entry).toHaveProperty('timestamp'); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(Array.isArray(body.entries)).toBe(true); - expect(body.entries.length).toBeGreaterThan(0); - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - await api.dispose(); }); test('pagination params are accepted', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log?limit=5', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/event-log?limit=5', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + expect(body.entries.length).toBeLessThanOrEqual(5); }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.entries.length).toBeLessThanOrEqual(5); - await api.dispose(); }); test('GET /api/event-log/export returns NDJSON', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log/export', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); + await withApi(async (api) => { + const res = await api.get('/api/event-log/export', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); - // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array - const contentType = res.headers()['content-type'] ?? ''; - expect(contentType).toContain('ndjson'); + // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array + const contentType = res.headers()['content-type'] ?? ''; + expect(contentType).toContain('ndjson'); - const text = await res.text(); - // Each non-empty line must be valid JSON - const lines = text.trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - } - await api.dispose(); + const text = await res.text(); + // Each non-empty line must be valid JSON + const lines = text.trim().split('\n').filter(Boolean); + expect(lines.length).toBeGreaterThan(0); + for (const line of lines) { + expect(() => JSON.parse(line)).not.toThrow(); + } + }); }); test('export without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/event-log/export'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/event-log/export'); + expect(res.status()).toBe(401); + }); }); }); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts index 8879bfe..51bc8f4 100644 --- a/tests/e2e/specs/07-env.e2e.ts +++ b/tests/e2e/specs/07-env.e2e.ts @@ -7,94 +7,104 @@ */ import { test, expect, request } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; const state: SmokeTestState = loadState(); const { baseUrl, sessionId } = state; +async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + const api = await request.newContext({ baseURL: baseUrl }); + try { + await fn(api); + } finally { + await api.dispose(); + } +} + test.describe('Env / credentials – /api/env', () => { test('GET /api/env returns 401 without auth', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/env'); - expect(res.status()).toBe(401); - await api.dispose(); + await withApi(async (api) => { + const res = await api.get('/api/env'); + expect(res.status()).toBe(401); + }); }); test('GET /api/env with session returns credential metadata', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.get('/api/env', { - headers: { 'X-Session-ID': sessionId }, + await withApi(async (api) => { + const res = await api.get('/api/env', { + headers: { 'X-Session-ID': sessionId }, + }); + expect(res.status()).toBe(200); + const body = await res.json(); + // In DB mode the response should indicate that credentials are present + expect(body).toHaveProperty('hasCredentials', true); }); - expect(res.status()).toBe(200); - const body = await res.json(); - // In DB mode the response should indicate that credentials are present - expect(body).toHaveProperty('hasCredentials', true); - await api.dispose(); }); test('POST /api/env – invalid GROUP_CRED returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: 'not-a-valid-bfgroup-credential', - SHARE_CRED: state.shareCredentials[0], - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: 'not-a-valid-bfgroup-credential', + SHARE_CRED: state.shareCredentials[0], + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – invalid SHARE_CRED returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: 'not-a-valid-bfshare-credential', - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: 'not-a-valid-bfshare-credential', + RELAYS: [`ws://127.0.0.1:${state.port}`], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – invalid relay URL returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: ['not-a-websocket-url'], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: ['not-a-websocket-url'], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env – empty RELAYS returns 400', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: [], - }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + headers: { 'X-Session-ID': sessionId }, + data: { + GROUP_CRED: state.groupCredential, + SHARE_CRED: state.shareCredentials[0], + RELAYS: [], + }, + }); + expect(res.status()).toBe(400); }); - expect(res.status()).toBe(400); - await api.dispose(); }); test('POST /api/env without auth returns 401', async () => { - const api = await request.newContext({ baseURL: baseUrl }); - const res = await api.post('/api/env', { - data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + await withApi(async (api) => { + const res = await api.post('/api/env', { + data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, + }); + expect(res.status()).toBe(401); }); - expect(res.status()).toBe(401); - await api.dispose(); }); }); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 5e00398..200fcd6 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -48,6 +48,9 @@ test.describe('UI – Authenticated app', () => { // The Signer tab or its content should be visible after login const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); await expect(signerTab).toBeVisible({ timeout: 8_000 }); + await signerTab.click(); + await expect(page.locator('body')).toContainText(/server signer:\s*(running|starting|stopped)/i, { timeout: 8_000 }); + await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); }); test('Configure tab is accessible', async ({ page }) => { @@ -104,6 +107,7 @@ test.describe('UI – Onboarding already completed', () => { test('/ does not show onboarding when DB is initialised', async ({ page }) => { await page.goto(baseUrl); // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear - await expect(page.locator('body')).not.toContainText('Admin Secret', { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/\badmin[\s_-]*secret\b/i, { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/\bset[\s_-]*up\b/i, { timeout: 6_000 }); }); }); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index a05698d..d690518 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -34,7 +34,7 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ groupCredential: z.string().min(1, 'groupCredential must be non-empty'), shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), groupPubkeyHex: z.string(), - adminUsername: z.string(), + adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), }); diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 654f20d..dc00aea 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,9 +1,30 @@ import { describe, expect, test } from 'bun:test'; +import fs from 'fs'; +import path from 'path'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +function loadFixtureTestKeysetSecret(): string | undefined { + const fixturePath = path.resolve('tests/e2e/smoke-test-defaults.json'); + if (!fs.existsSync(fixturePath)) return undefined; + try { + const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + if (typeof raw !== 'object' || raw === null) return undefined; + const testNsecHex = (raw as Record).testNsecHex; + return typeof testNsecHex === 'string' && testNsecHex.trim().length > 0 ? testNsecHex : undefined; + } catch { + return undefined; + } +} + const TEST_KEYSET_SECRET = process.env.TEST_KEYSET_SECRET ?? - 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + process.env.TEST_NSEC_HEX ?? + loadFixtureTestKeysetSecret(); +if (!TEST_KEYSET_SECRET) { + throw new Error( + 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' + ); +} describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts new file mode 100644 index 0000000..f2856c2 --- /dev/null +++ b/tests/routes/helpers/script-runner.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { + buildScriptEnv, + ISOLATED_ENV_KEYS, + ISOLATED_ENV_PREFIXES, +} from './script-runner'; + +describe('buildScriptEnv', () => { + test('keeps forced values and blocks reserved override keys', () => { + const env = buildScriptEnv( + { + NODE_ENV: 'production', + DB_PATH: '/tmp/attacker.db', + ENV_FILE_PATH: '/tmp/attacker.env', + ADMIN_SECRET: 'nope', + CUSTOM_FLAG: '1', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env.NODE_ENV).toBe('test'); + expect(env.DB_PATH).toBe('/tmp/forced.db'); + expect(env.ENV_FILE_PATH).toBe('/tmp/forced.env'); + expect(env.ADMIN_SECRET).toBeUndefined(); + expect(env.CUSTOM_FLAG).toBe('1'); + }); + + test('blocks override keys that match isolated prefixes', () => { + const reservedPrefix = ISOLATED_ENV_PREFIXES[0]; + const env = buildScriptEnv( + { + [`${reservedPrefix}WINDOW`]: '123', + [`${reservedPrefix}MAX`]: '999', + SAFE_KEY: 'ok', + }, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + + expect(env[`${reservedPrefix}WINDOW`]).toBeUndefined(); + expect(env[`${reservedPrefix}MAX`]).toBeUndefined(); + expect(env.SAFE_KEY).toBe('ok'); + }); + + test('removes reserved keys inherited from process.env', () => { + const preserved = process.env[ISOLATED_ENV_KEYS[0]]; + process.env[ISOLATED_ENV_KEYS[0]] = 'should-not-leak'; + try { + const env = buildScriptEnv( + {}, + { + dbPath: '/tmp/forced.db', + envFilePath: '/tmp/forced.env', + } + ); + expect(env[ISOLATED_ENV_KEYS[0]]).toBe('test'); + } finally { + if (preserved === undefined) delete process.env[ISOLATED_ENV_KEYS[0]]; + else process.env[ISOLATED_ENV_KEYS[0]] = preserved; + } + }); +}); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 1d23378..8d333c7 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -5,7 +5,7 @@ import { pathToFileURL } from 'url'; export const PROJECT_ROOT = pathToFileURL(process.cwd() + '/').href; -const ISOLATED_ENV_KEYS = [ +export const ISOLATED_ENV_KEYS = [ 'NODE_ENV', 'HEADLESS', 'AUTH_ENABLED', @@ -27,11 +27,28 @@ const ISOLATED_ENV_KEYS = [ 'ENV_FILE_PATH', ] as const; -const ISOLATED_ENV_PREFIXES = [ +export const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', ] as const; -function buildScriptEnv(overrides: Record): Record { +function isBlockedEnvKey(key: string): boolean { + return ISOLATED_ENV_KEYS.includes(key as (typeof ISOLATED_ENV_KEYS)[number]) || + ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); +} + +function sanitizeOverrides(overrides: Record): Record { + const sanitized: Record = {}; + for (const [key, value] of Object.entries(overrides)) { + if (isBlockedEnvKey(key)) continue; + sanitized[key] = value; + } + return sanitized; +} + +export function buildScriptEnv( + overrides: Record, + forced: { dbPath: string; envFilePath: string } +): Record { const nextEnv: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (typeof value === 'string') { @@ -39,37 +56,35 @@ function buildScriptEnv(overrides: Record): Record key.startsWith(prefix))) { + if (isBlockedEnvKey(key)) { delete nextEnv[key]; } } + const sanitizedOverrides = sanitizeOverrides(overrides); + return { ...nextEnv, + ...sanitizedOverrides, NODE_ENV: 'test', - ...overrides, + DB_PATH: forced.dbPath, + ENV_FILE_PATH: forced.envFilePath, }; } /** * Runs route code in an isolated Bun subprocess and returns the parsed @@RESULT@@ JSON payload. - * Uses T=any by default so callers without an explicit type can access result properties (e.g. out.status). */ -export function runRouteScript(code: string, env: Record = {}): T { +export function runRouteScript>(code: string, env: Record = {}): T { const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'igloo-route-')); try { const runner = path.join(tmpDir, 'runner.ts'); writeFileSync(runner, code, 'utf8'); - const isolatedEnv = buildScriptEnv({ - ENV_FILE_PATH: path.join(tmpDir, '.env'), - DB_PATH: path.join(tmpDir, 'igloo.db'), - ...env + const isolatedEnv = buildScriptEnv(env, { + envFilePath: path.join(tmpDir, '.env'), + dbPath: path.join(tmpDir, 'igloo.db'), }); const result = Bun.spawnSync({ From 944ec63ecc6d041e94a075f79042cffe72c9bf4a Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 08:58:41 -0600 Subject: [PATCH 35/69] fix: harden smoke test harness and teardown behavior --- frontend/components/ui/peer-list.tsx | 2 +- src/class/relay.test.ts | 31 +++++++++++++++------- src/routes/utils.ts | 2 -- tests/e2e/cosigner.mjs | 26 +++++++++++++++++- tests/e2e/global-teardown.ts | 12 ++++++--- tests/e2e/specs/08-ui.e2e.ts | 6 ++--- tests/routes/env.db-mode.spec.ts | 8 +++++- tests/routes/helpers/script-runner.spec.ts | 11 ++++---- 8 files changed, 72 insertions(+), 26 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 192a01b..df9a180 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -394,7 +394,7 @@ const PeerList: React.FC = ({ window.removeEventListener('peerStatusUpdate', handlePeerUpdate as EventListener); window.removeEventListener('peerPingUpdate', handlePeerUpdate as EventListener); }; - }, [authHeaders, isSignerRunning]); + }, [isSignerRunning]); // Ping individual peer const handlePingPeer = useCallback(async (peerPubkey: string) => { diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 6d716cb..cb04e83 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -2,13 +2,20 @@ import { describe, expect, it } from 'bun:test'; import { NostrRelay } from './relay.js'; type FakeSocket = { - data: any; + data: unknown; sent: string[]; closed: boolean; send: (message: string) => void; close: () => void; }; +type RelayHandler = ReturnType; +type HandlerSocket = Parameters>[0]; + +function asHandlerSocket(socket: FakeSocket): HandlerSocket { + return socket as unknown as HandlerSocket; +} + function createFakeSocket(): FakeSocket { return { data: null, @@ -31,10 +38,11 @@ describe('NostrRelay REQ handling', () => { it('normalizes wrapped nostr-tools REQ filter arrays into subscriptions', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-1', [{ kinds: [1] }]])); expect(relay.subs.size).toBe(1); const [sub] = Array.from(relay.subs.values()); @@ -49,10 +57,11 @@ describe('NostrRelay REQ handling', () => { it('rejects REQ with an empty wrapped filter array', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-empty', []])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty', []])); expect(relay.subs.size).toBe(0); const messages = decodeSent(socket); @@ -62,19 +71,21 @@ describe('NostrRelay REQ handling', () => { it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); const handler = relay.handler(); - handler.open?.(socket as any); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-close', { kinds: [1] }])); expect(relay.subs.size).toBe(1); - handler.message?.(socket as any, JSON.stringify(['CLOSE', 'sub-close'])); + handler.message?.(ws, JSON.stringify(['CLOSE', 'sub-close'])); expect(relay.subs.size).toBe(0); - handler.message?.(socket as any, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-cleanup', { kinds: [1] }])); expect(relay.subs.size).toBe(1); - handler.close?.(socket as any, 1000, 'test'); + const closeHandler = handler.close as unknown as ((socketArg: HandlerSocket, code: number) => void) | undefined; + closeHandler?.(ws, 1000); expect(relay.subs.size).toBe(0); expect(socket.closed).toBe(true); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index a54206b..d277bd4 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -52,8 +52,6 @@ function extractIpv4MappedIpv6(hostname: string): string | null { const mappedPrefixes = [ /^::ffff:(.+)$/i, /^::ffff:0:(.+)$/i, - /^0:0:0:0:0:ffff:(.+)$/i, - /^0:0:0:0:ffff:0:(.+)$/i, ]; for (const pattern of mappedPrefixes) { const match = hostname.match(pattern); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 6b11c32..3bae33f 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -16,6 +16,30 @@ const { connectNode, } = await import('@frostr/igloo-core'); +const CONNECT_TIMEOUT_MS_RAW = process.env.SMOKE_COSIGNER_CONNECT_TIMEOUT_MS ?? '20000'; +const parsedConnectTimeout = Number.parseInt(CONNECT_TIMEOUT_MS_RAW, 10); +const CONNECT_TIMEOUT_MS = Number.isFinite(parsedConnectTimeout) && parsedConnectTimeout > 0 + ? parsedConnectTimeout + : 20000; + +async function connectWithTimeout(nodeInstance, relay) { + let timeoutId; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`connectNode timeout after ${CONNECT_TIMEOUT_MS}ms for relay ${relay}`)); + }, CONNECT_TIMEOUT_MS); + }); + + const connectionPromise = connectNode(nodeInstance); + connectionPromise.catch(() => {}); + + try { + await Promise.race([connectionPromise, timeoutPromise]); + } finally { + if (timeoutId) clearTimeout(timeoutId); + } +} + /** * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid * "Converting circular structure to JSON" TypeError from bubbling into outer catch. @@ -64,7 +88,7 @@ try { node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); console.log('[cosigner] Connecting to relay:', relayUrl); - await connectNode(node); + await connectWithTimeout(node, relayUrl); console.log('[cosigner] Connected. Pubkey:', node.pubkey); const filter = node.client?.filter; const privateFilter = node.client?._filter; diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index d5ea286..66347f0 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -81,14 +81,15 @@ export default async function globalTeardown(_config: FullConfig): Promise throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); } + let skipTempCleanup = false; try { const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; if (ageMs > MAX_STATE_AGE_MS) { console.warn( `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + - 'skipping process kill and temp cleanup to avoid affecting unrelated runs.' + 'skipping temp cleanup, but continuing process teardown to avoid leaks.' ); - return; + skipTempCleanup = true; } } catch (error) { console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); @@ -105,7 +106,7 @@ export default async function globalTeardown(_config: FullConfig): Promise const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); - const safeTmpDir = resolveSafeTmpDir(parsedState.tmpDir); + const safeTmpDir = skipTempCleanup ? null : resolveSafeTmpDir(parsedState.tmpDir); for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { if (!pid) continue; @@ -125,6 +126,11 @@ export default async function globalTeardown(_config: FullConfig): Promise await new Promise(r => setTimeout(r, 500)); + if (skipTempCleanup) { + console.warn('[teardown] Skipping temp dir removal because state file is stale.'); + return; + } + if (!safeTmpDir) { console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); return; diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 200fcd6..54f0181 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -106,8 +106,8 @@ test.describe('UI – Authenticated app', () => { test.describe('UI – Onboarding already completed', () => { test('/ does not show onboarding when DB is initialised', async ({ page }) => { await page.goto(baseUrl); - // The onboarding "ADMIN_SECRET" or "setup" copy should NOT appear - await expect(page.locator('body')).not.toContainText(/\badmin[\s_-]*secret\b/i, { timeout: 6_000 }); - await expect(page.locator('body')).not.toContainText(/\bset[\s_-]*up\b/i, { timeout: 6_000 }); + // Specific onboarding copy should not appear once DB is initialized. + await expect(page.locator('body')).not.toContainText(/enter the admin secret to begin setting up your igloo server/i, { timeout: 6_000 }); + await expect(page.locator('body')).not.toContainText(/create your admin account to secure your igloo server/i, { timeout: 6_000 }); }); }); diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index dc00aea..7310f76 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,10 +1,16 @@ import { describe, expect, test } from 'bun:test'; import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; function loadFixtureTestKeysetSecret(): string | undefined { - const fixturePath = path.resolve('tests/e2e/smoke-test-defaults.json'); + const fixturePath = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'e2e', + 'smoke-test-defaults.json' + ); if (!fs.existsSync(fixturePath)) return undefined; try { const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index f2856c2..27e546f 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,8 +48,9 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const preserved = process.env[ISOLATED_ENV_KEYS[0]]; - process.env[ISOLATED_ENV_KEYS[0]] = 'should-not-leak'; + const reservedKey = ISOLATED_ENV_KEYS[0]; + const preserved = process.env[reservedKey]; + process.env[reservedKey] = 'should-not-leak'; try { const env = buildScriptEnv( {}, @@ -58,10 +59,10 @@ describe('buildScriptEnv', () => { envFilePath: '/tmp/forced.env', } ); - expect(env[ISOLATED_ENV_KEYS[0]]).toBe('test'); + expect(env[reservedKey]).toBe('test'); } finally { - if (preserved === undefined) delete process.env[ISOLATED_ENV_KEYS[0]]; - else process.env[ISOLATED_ENV_KEYS[0]] = preserved; + if (preserved === undefined) delete process.env[reservedKey]; + else process.env[reservedKey] = preserved; } }); }); From 557e4a5b593fca00684053319096ef1784560e03 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 11:13:11 -0600 Subject: [PATCH 36/69] fix: tighten e2e cleanup and env test safeguards --- .github/workflows/ci.yml | 3 +- src/class/relay.ts | 2 +- src/routes/env.ts | 2 +- src/routes/utils.test.ts | 6 +++ tests/e2e/global-setup.ts | 11 +++++ tests/e2e/specs/05-admin.e2e.ts | 57 +++++++++++++--------- tests/routes/helpers/script-runner.spec.ts | 6 ++- 7 files changed, 59 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e863b1..8da2558 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,7 +104,7 @@ jobs: audit_output="$(cat "$audit_log")" echo "bun audit failed after ${last_attempt:-3} attempts with exit code ${last_exit}" - if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' "$audit_log"; then + if grep -Eiq 'network|registry|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT' <<< "$audit_output"; then echo "bun audit failed after retries due to network/registry error: $audit_output" else echo "bun audit failed after retries - vulnerabilities detected: $audit_output" @@ -114,6 +114,7 @@ jobs: - name: Check for secrets # Pinned to immutable commit (v3.93.4) for supply-chain safety. + # Maintenance: periodically verify this SHA still corresponds to the intended upstream release. uses: trufflesecurity/trufflehog@7c0734f987ad0bb30ee8da210773b800ee2016d3 with: path: ./ diff --git a/src/class/relay.ts b/src/class/relay.ts index 2cf6914..e6a518e 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -201,7 +201,7 @@ class RelaySession { this.log.debug('event:', event) if (!Nostr.verify_event(event)) { - this.log.info(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) + this.log.debug(`event failed validation (id=${event.id.slice(0, 8)} kind=${event.kind})`) this.send([ 'OK', event.id, false, 'event failed validation' ]) return } diff --git a/src/routes/env.ts b/src/routes/env.ts index a34899f..20667f4 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -402,7 +402,7 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged if (updatingCredentials) { // Set the timestamp explicitly here to avoid relying on downstream helpers // for correctness, then perform a single write. - (env as any).CREDENTIALS_SAVED_AT = new Date().toISOString(); + env.CREDENTIALS_SAVED_AT = new Date().toISOString(); } const writeOk = await writeEnvFile(env); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index a7b4d6a..f836a77 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -62,6 +62,12 @@ describe('getValidRelays', () => { }); }); + it('filters IPv4-mapped IPv6 ::ffff:0: relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[::ffff:0:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 7d58bd6..4c97003 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -279,6 +279,17 @@ export default async function globalSetup(_config: FullConfig): Promise { const { generateKeysetWithSecret, decodeGroup } = iglooCore; const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); + if ( + !Array.isArray(shareCredentials) || + shareCredentials.length < 2 || + typeof shareCredentials[0] !== 'string' || + typeof shareCredentials[1] !== 'string' + ) { + throw new Error( + `Invalid keyset from generateKeysetWithSecret: shareCredentials.length=${Array.isArray(shareCredentials) ? shareCredentials.length : 'non-array'} ` + + `shareCredentials=${JSON.stringify(shareCredentials)} groupCredentialType=${typeof groupCredential}` + ); + } const group = decodeGroup(groupCredential); const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); state.groupCredential = groupCredential; diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index f801ea4..8a51568 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -93,32 +93,43 @@ test.describe('Admin – API keys', () => { test('revoked API key returns 401', async () => { await withApi(async (api) => { - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); + let createdApiKey: { id: string | number; token: string } | null = null; + try { + // Create a fresh key + const createRes = await api.post('/api/admin/api-keys', { + headers: { 'X-Session-ID': sessionId }, + data: { label: 'revoke-test-key' }, + }); + expect(createRes.status()).toBe(201); + const { apiKey } = await createRes.json(); + createdApiKey = apiKey; - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(beforeRes.status()).toBe(200); + // Verify it works on an auth-protected endpoint + const beforeRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': createdApiKey.token }, + }); + expect(beforeRes.status()).toBe(200); - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: apiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); + // Revoke it + const revokeRes = await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdApiKey.id, reason: 'smoke-test cleanup' }, + }); + expect(revokeRes.status()).toBe(200); - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(afterRes.status()).toBe(401); + // Now the revoked key should be rejected + const afterRes = await api.get('/api/event-log', { + headers: { 'X-API-Key': createdApiKey.token }, + }); + expect(afterRes.status()).toBe(401); + } finally { + if (createdApiKey) { + await api.post('/api/admin/api-keys/revoke', { + headers: { 'X-Session-ID': sessionId }, + data: { apiKeyId: createdApiKey.id, reason: 'test cleanup' }, + }).catch(() => null); + } + } }); }); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index 27e546f..acae6df 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,7 +48,9 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const reservedKey = ISOLATED_ENV_KEYS[0]; + const reservedKey = ISOLATED_ENV_KEYS.find((key) => key !== 'NODE_ENV'); + expect(reservedKey).toBeDefined(); + if (!reservedKey) throw new Error('Expected at least one reserved key other than NODE_ENV'); const preserved = process.env[reservedKey]; process.env[reservedKey] = 'should-not-leak'; try { @@ -59,7 +61,7 @@ describe('buildScriptEnv', () => { envFilePath: '/tmp/forced.env', } ); - expect(env[reservedKey]).toBe('test'); + expect(env[reservedKey]).toBeUndefined(); } finally { if (preserved === undefined) delete process.env[reservedKey]; else process.env[reservedKey] = preserved; From 816eb0fe35e5f648e45f05a656a28aa5174e0a83 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 13:30:21 -0600 Subject: [PATCH 37/69] fix: pin Bun version in docker and CI workflows --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- Dockerfile | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8da2558..ec46391 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -57,7 +57,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile @@ -77,7 +77,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf3579b..a42ac50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,7 +31,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.3.3 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/Dockerfile b/Dockerfile index 65fae40..0052609 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller production image -FROM oven/bun:latest AS build +FROM oven/bun:1.3.3 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # --- Production stage --- -FROM oven/bun:latest AS production +FROM oven/bun:1.3.3 AS production WORKDIR /app From 5f8713bb180bc6637c124e0feda29df796c36e28 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 15:26:02 -0600 Subject: [PATCH 38/69] fix: harden e2e smoke tests and peer-list collapse behavior --- frontend/components/ui/peer-list.tsx | 20 ++++++++++++++++++-- llm/implementation/e2e-smoke-tests.md | 3 ++- src/class/relay.test.ts | 21 +++++++++++++++++++++ src/routes/utils.test.ts | 12 ++++++++++++ src/routes/utils.ts | 2 +- tests/e2e/global-setup.ts | 5 +++++ tests/e2e/specs/05-admin.e2e.ts | 3 ++- tests/e2e/specs/06-event-log.e2e.ts | 12 ++++++++++++ tests/e2e/state.ts | 7 ++++++- tests/routes/helpers/script-runner.spec.ts | 3 ++- 10 files changed, 81 insertions(+), 7 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index df9a180..f8c43ec 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -166,6 +166,7 @@ const PeerList: React.FC = ({ defaultExpanded = false }) => { const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [shouldRenderContent, setShouldRenderContent] = useState(defaultExpanded); const [peers, setPeers] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -184,6 +185,19 @@ const PeerList: React.FC = ({ } }, [defaultExpanded]); + useEffect(() => { + if (isExpanded) { + setShouldRenderContent(true); + } + }, [isExpanded]); + + const handleCollapseTransitionEnd = useCallback((event: React.TransitionEvent) => { + if (event.target !== event.currentTarget) return; + if (!isExpanded) { + setShouldRenderContent(false); + } + }, [isExpanded]); + const setPolicyBusyState = useCallback((key: string, busy: boolean) => { setPolicySavingPeers(prev => { const next = new Set(prev); @@ -348,7 +362,7 @@ const PeerList: React.FC = ({ return () => { isActive = false; }; - }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers]); + }, [isSignerRunning, groupCredential, shareCredential, disabled, fetchSelfPubkey, fetchPeers, authHeaders]); // Unified handler for peer status and ping updates const handlePeerUpdate = (event: CustomEvent) => { @@ -683,8 +697,10 @@ const PeerList: React.FC = ({ isExpanded ? "max-h-[400px] opacity-100" : "max-h-0 opacity-0" )} aria-hidden={!isExpanded} + inert={!isExpanded} + onTransitionEnd={handleCollapseTransitionEnd} > - {isExpanded && ( + {shouldRenderContent && (
{isLoading ? (
diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index e75507f..0bb18d7 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -33,6 +33,7 @@ Prerequisites: - `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). - `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). - Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. +- Admin credentials must be provided before running tests. Set `SMOKE_ADMIN_SECRET`, `SMOKE_ADMIN_USERNAME`, and `SMOKE_ADMIN_PASSWORD`, or provide `tests/e2e/smoke-test.local.json`; otherwise `tests/e2e/global-setup.ts` exits early. ## File Structure @@ -75,7 +76,7 @@ The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, l |---|---|---| | `HOST_PORT` | `18002` | Fixed test port | | `HOST_NAME` | `127.0.0.1` | Loopback only | -| `ADMIN_SECRET` | `SmokeTestAdmin1` | Deterministic | +| `ADMIN_SECRET` | from env/fixture (e.g. `$SMOKE_ADMIN_SECRET`) | Loaded from environment or local fixture — do not commit secrets | | `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | | `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | | `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index cb04e83..5805262 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -68,6 +68,27 @@ describe('NostrRelay REQ handling', () => { expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); }); + it('accepts canonical multi-filter REQ payloads and creates a subscription', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + const authorHex = 'f'.repeat(64); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-multi', { kinds: [1] }, { authors: [authorHex] }])); + + expect(relay.subs.size).toBe(1); + const [sub] = Array.from(relay.subs.values()); + expect(sub?.sub_id).toBe('sub-multi'); + expect(sub?.filters).toHaveLength(2); + expect((sub?.filters[0] as { kinds?: number[] }).kinds).toEqual([1]); + expect((sub?.filters[1] as { authors?: string[] }).authors).toEqual([authorHex]); + + const messages = decodeSent(socket); + expect(messages).toContainEqual(['EOSE', 'sub-multi']); + }); + it('removes composed-key subscriptions when CLOSE/unsubscribe is processed', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index f836a77..b9ab366 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -68,6 +68,18 @@ describe('getValidRelays', () => { }); }); + it('filters expanded IPv6 loopback relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:0:0:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + + it('filters expanded IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://[0:0:0:0:0:ffff:7f00:1]:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('keeps localhost relay when localhost relays are explicitly allowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { expect(getValidRelays('["ws://127.0.0.1:18002"]', { fallbackToDefault: false })) diff --git a/src/routes/utils.ts b/src/routes/utils.ts index d277bd4..4f84237 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -64,7 +64,7 @@ function extractIpv4MappedIpv6(hostname: string): string | null { function isLoopbackRelayHost(hostname: string): boolean { let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); - if (normalized === 'localhost' || normalized === '::1') return true; + if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); if (mappedIpv4) { diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index 4c97003..c5347a2 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -162,6 +162,11 @@ async function resolvePort(host: string, preferredPort: number): Promise return preferredPort; } const fallbackPort = await reserveRandomPort(host); + if (!Number.isInteger(fallbackPort) || fallbackPort < 1 || fallbackPort > 65535) { + throw new Error( + `[setup] Failed to reserve a valid fallback port after preferred port ${preferredPort} was busy (got: ${fallbackPort})`, + ); + } console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); return fallbackPort; } diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts index 8a51568..5785ceb 100644 --- a/tests/e2e/specs/05-admin.e2e.ts +++ b/tests/e2e/specs/05-admin.e2e.ts @@ -7,8 +7,9 @@ import { test, expect, request } from '@playwright/test'; import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; +import type { SmokeTestState } from '../state.js'; -const state = loadState(); +const state: SmokeTestState = loadState(); const { baseUrl, sessionId, adminUsername } = state; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index 2f15fc3..aaf4f26 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -21,6 +21,18 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + test.beforeAll(async () => { + await withApi(async (api) => { + const seedRes = await api.post('/api/sign', { + headers: { 'X-Session-ID': sessionId }, + data: { message: 'b'.repeat(64) }, + }); + if (!seedRes.ok()) { + throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); + } + }); + }); + test('returns 401 without auth', async () => { await withApi(async (api) => { const res = await api.get('/api/event-log'); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index d690518..a27f818 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -64,7 +64,12 @@ const STUB: SmokeTestState = { */ export function loadState(): SmokeTestState { const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile) return STUB; + if (!stateFile) { + return { + ...STUB, + shareCredentials: [...STUB.shareCredentials], + }; + } try { const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); diff --git a/tests/routes/helpers/script-runner.spec.ts b/tests/routes/helpers/script-runner.spec.ts index acae6df..b14f9c4 100644 --- a/tests/routes/helpers/script-runner.spec.ts +++ b/tests/routes/helpers/script-runner.spec.ts @@ -48,7 +48,8 @@ describe('buildScriptEnv', () => { }); test('removes reserved keys inherited from process.env', () => { - const reservedKey = ISOLATED_ENV_KEYS.find((key) => key !== 'NODE_ENV'); + const forcedKeys = new Set(['NODE_ENV', 'DB_PATH', 'ENV_FILE_PATH']); + const reservedKey = ISOLATED_ENV_KEYS.find((key) => !forcedKeys.has(key)); expect(reservedKey).toBeDefined(); if (!reservedKey) throw new Error('Expected at least one reserved key other than NODE_ENV'); const preserved = process.env[reservedKey]; From 3f6481f35ee201218ac6bbc125737d644d0e7748 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 16:48:33 -0600 Subject: [PATCH 39/69] fix: harden relay limits and teardown safety --- playwright.config.ts | 5 +-- src/class/relay.test.ts | 49 +++++++++++++++++++++++++++ src/class/relay.ts | 7 ++-- tests/e2e/cosigner.mjs | 4 ++- tests/e2e/global-teardown.ts | 35 +++++++++++++++++++ tests/routes/helpers/script-runner.ts | 22 ++++++++++-- 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/playwright.config.ts b/playwright.config.ts index 42fdb5b..97b856f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -24,10 +24,11 @@ export default defineConfig({ }, projects: [ - // Pure API specs (01–07) – use request context only, no browser + // Pure API specs – use request context only, no browser { name: 'api', - testMatch: ['**/0[1-7]-*.e2e.ts'], + testMatch: ['**/[0-9][0-9]-*.e2e.ts'], + testIgnore: ['**/08-ui.e2e.ts'], }, // Browser UI spec (08) – needs a real browser { diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 5805262..2c9db2a 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -110,4 +110,53 @@ describe('NostrRelay REQ handling', () => { expect(relay.subs.size).toBe(0); expect(socket.closed).toBe(true); }); + + it('applies filter.limit to matched events only', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + const unmatched = { + id: 'u'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 1, + kind: 9, + tags: [], + content: '', + sig: 'b'.repeat(128), + } as Parameters[0]; + const matchedA = { + id: 'c'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 2, + kind: 1, + tags: [], + content: '', + sig: 'd'.repeat(128), + } as Parameters[0]; + const matchedB = { + id: 'e'.repeat(64), + pubkey: 'a'.repeat(64), + created_at: 3, + kind: 1, + tags: [], + content: '', + sig: 'f'.repeat(128), + } as Parameters[0]; + + relay.store(unmatched); + relay.store(matchedA); + relay.store(matchedB); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-limit', { kinds: [1], limit: 1 }])); + + const messages = decodeSent(socket); + const eventMessages = messages.filter((msg) => msg[0] === 'EVENT'); + expect(eventMessages).toHaveLength(1); + expect(eventMessages[0]?.[1]).toBe('sub-limit'); + expect((eventMessages[0]?.[2] as { kind?: number }).kind).toBe(1); + expect(messages).toContainEqual(['EOSE', 'sub-limit']); + }); }); diff --git a/src/class/relay.ts b/src/class/relay.ts index e6a518e..b7f2904 100644 --- a/src/class/relay.ts +++ b/src/class/relay.ts @@ -241,9 +241,12 @@ class RelaySession { this.send(['EVENT', sub_id, event]) this.log.client(`event matched in cache: ${event.id}`) this.log.client(`event matched subscription: ${sub_id}`) + // Decrement only when we actually sent a matching event. + if (limit_count !== undefined) { + limit_count -= 1 + if (limit_count === 0) break + } } - // Update the limit count. - if (limit_count !== undefined) limit_count -= 1 } } } diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 3bae33f..53cf269 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -103,7 +103,9 @@ try { } } catch (err) { - console.error('[cosigner] Failed to start:', err instanceof Error ? err.message : String(err)); + const message = err instanceof Error ? err.message : String(err); + const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; + console.error(`[cosigner] Failed to start: ${message}${stack}`); process.exit(2); } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 66347f0..071cf3b 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -6,6 +6,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { execFileSync } from 'child_process'; import type { FullConfig } from '@playwright/test'; import type { SmokeTestState } from './state.js'; @@ -31,6 +32,37 @@ function isProcessRunning(pid: number): boolean { } } +function getProcessCommand(pid: number): string | null { + try { + const output = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const cmd = output.trim(); + return cmd.length > 0 ? cmd : null; + } catch { + return null; + } +} + +function validateProcessIdentity(pid: number, label: 'co-signer' | 'server'): boolean { + const command = getProcessCommand(pid); + if (!command) { + console.warn(`[teardown] Could not read command line for ${label} pid ${pid}; skipping SIGTERM for safety.`); + return false; + } + + const expectedFragment = label === 'co-signer' ? 'tests/e2e/cosigner.mjs' : 'src/server.ts'; + const matches = command.includes(expectedFragment); + if (!matches) { + console.warn( + `[teardown] ${label} pid ${pid} command did not match expected identity (${expectedFragment}); ` + + `actual="${command}". Skipping SIGTERM for safety.` + ); + } + return matches; +} + function resolveSafeTmpDir(rawTmpDir: unknown): string | null { if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); @@ -114,6 +146,9 @@ export default async function globalTeardown(_config: FullConfig): Promise console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); continue; } + if (!validateProcessIdentity(pid, label)) { + continue; + } try { process.kill(pid, 'SIGTERM'); console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 8d333c7..6ba153a 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -31,11 +31,22 @@ export const ISOLATED_ENV_PREFIXES = [ 'RATE_LIMIT_', ] as const; +const ERROR_PREVIEW_MAX_CHARS = 200; + function isBlockedEnvKey(key: string): boolean { return ISOLATED_ENV_KEYS.includes(key as (typeof ISOLATED_ENV_KEYS)[number]) || ISOLATED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); } +function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string { + const compact = raw.replace(/\s+/g, ' ').trim(); + if (!compact) return '(empty)'; + const redacted = compact + .replace(/(admin_secret|session_secret|password|api[_-]?key|token)\s*[:=]\s*["']?[^"'\s]+/ig, '$1=') + .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); + return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...(truncated)` : redacted; +} + function sanitizeOverrides(overrides: Record): Record { const sanitized: Record = {}; for (const [key, value] of Object.entries(overrides)) { @@ -97,8 +108,10 @@ export function runRouteScript>(code: string, env: R }); if (result.exitCode !== 0) { + const stderrPreview = toSafePreview(result.stderr.toString()); + const stdoutPreview = toSafePreview(result.stdout.toString()); throw new Error( - `route script failed: status=${result.exitCode} stderr="${result.stderr.toString()}" stdout="${result.stdout.toString()}"` + `route script failed: status=${result.exitCode} stderr_preview="${stderrPreview}" stdout_preview="${stdoutPreview}"` ); } @@ -106,7 +119,7 @@ export function runRouteScript>(code: string, env: R const stdout = result.stdout.toString().trim(); const line = stdout.split('\n').reverse().find(l => l.includes(marker)); if (!line) { - throw new Error(`route script missing result marker: ${stdout}`); + throw new Error(`route script missing result marker; stdout_preview="${toSafePreview(stdout)}"`); } const rawJson = line.slice(line.indexOf(marker) + marker.length); try { @@ -114,7 +127,10 @@ export function runRouteScript>(code: string, env: R return parsed as T; } catch (error) { const detail = error instanceof Error ? error.message : String(error); - throw new Error(`route script returned invalid JSON marker payload: ${detail}; raw="${rawJson}"; stdout="${stdout}"`); + throw new Error( + `route script returned invalid JSON marker payload: ${detail}; ` + + `raw_preview="${toSafePreview(rawJson)}"; stdout_preview="${toSafePreview(stdout)}"` + ); } } finally { rmSync(tmpDir, { recursive: true, force: true }); From b0681047bd7f6ba263a48d4683d1a8d6c06da13f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 17:54:51 -0600 Subject: [PATCH 40/69] fix: tighten e2e ui and loopback validation --- frontend/components/ui/peer-list.tsx | 19 ++++++++++++------- frontend/components/ui/tooltip.tsx | 3 +++ src/routes/utils.test.ts | 6 ++++++ src/routes/utils.ts | 2 +- tests/e2e/cosigner.mjs | 8 ++------ tests/e2e/specs/06-event-log.e2e.ts | 2 +- tests/e2e/specs/08-ui.e2e.ts | 4 ++++ tests/e2e/state.ts | 9 +++++++-- tests/routes/env.db-mode.spec.ts | 10 ++++++++-- 9 files changed, 44 insertions(+), 19 deletions(-) diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index f8c43ec..7a1471f 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -178,6 +178,7 @@ const PeerList: React.FC = ({ const [policySavingPeers, setPolicySavingPeers] = useState>(new Set()); const [policyPeerErrors, setPolicyPeerErrors] = useState>(new Map()); const hasUserToggledRef = useRef(false); + const panelRef = useRef(null); useEffect(() => { if (defaultExpanded && !hasUserToggledRef.current) { @@ -191,6 +192,13 @@ const PeerList: React.FC = ({ } }, [isExpanded]); + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + if (isExpanded) panel.removeAttribute('inert'); + else panel.setAttribute('inert', ''); + }, [isExpanded]); + const handleCollapseTransitionEnd = useCallback((event: React.TransitionEvent) => { if (event.target !== event.currentTarget) return; if (!isExpanded) { @@ -638,14 +646,11 @@ const PeerList: React.FC = ({ position="right" width="w-64" focusable + ariaLabel="Peer list help" trigger={( - + )} content={

Shows the signing peers in your FROSTR group with online/offline status and ping latency. Use the refresh button to ping all peers and update their status.

@@ -692,12 +697,12 @@ const PeerList: React.FC = ({ {/* Collapsible Content */}
{shouldRenderContent && ( diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx index 71bfead..543eca3 100644 --- a/frontend/components/ui/tooltip.tsx +++ b/frontend/components/ui/tooltip.tsx @@ -10,6 +10,7 @@ interface TooltipProps { width?: string; triggerClassName?: string; focusable?: boolean; + ariaLabel?: string; } const Tooltip: React.FC = ({ @@ -20,6 +21,7 @@ const Tooltip: React.FC = ({ width = 'w-72', triggerClassName, focusable = false, + ariaLabel, }) => { const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); @@ -123,6 +125,7 @@ const Tooltip: React.FC = ({ type="button" {...commonProps} className={cn('inline-flex align-middle', triggerClassName)} + aria-label={ariaLabel} > {triggerContent} diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index b9ab366..13d1a98 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -50,6 +50,12 @@ describe('getValidRelays', () => { }); }); + it('filters localhost hostname with trailing dot when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://localhost.:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 4f84237..767d268 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -63,7 +63,7 @@ function extractIpv4MappedIpv6(hostname: string): string | null { } function isLoopbackRelayHost(hostname: string): boolean { - let normalized = hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase(); + let normalized = hostname.replace(/\.+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase(); if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 53cf269..1d608cd 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -91,15 +91,11 @@ try { await connectWithTimeout(node, relayUrl); console.log('[cosigner] Connected. Pubkey:', node.pubkey); const filter = node.client?.filter; - const privateFilter = node.client?._filter; if (filter !== undefined) { console.log('[cosigner] Filter (public):', safeStringify(filter)); - } else if (privateFilter !== undefined) { - // TODO: Remove private fallback once @frostr/igloo-core exposes a stable public filter accessor. - console.warn('[cosigner] Filter fallback in use: node.client._filter (private internals)'); - console.log('[cosigner] Filter (private fallback):', safeStringify(privateFilter)); } else { - console.warn('[cosigner] Filter unavailable on node.client (public and private fields missing)'); + // TODO: Track upstream accessor support in @frostr/igloo-core if filter visibility is needed. + console.warn('[cosigner] Filter unavailable on node.client.filter (public accessor missing)'); } } catch (err) { diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index aaf4f26..deaa742 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -1,6 +1,6 @@ /** * UI event-log smoke tests. - * Signing operations performed in 04-sign.spec.ts will have produced log entries. + * Signing operations performed in 04-sign.e2e.ts will have produced log entries. */ import { test, expect, request } from '@playwright/test'; diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 54f0181..1ce7913 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -80,6 +80,10 @@ test.describe('UI – Authenticated app', () => { }); test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { + const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); + await expect(signerTab).toBeVisible({ timeout: 8_000 }); + await signerTab.click(); + // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). // It renders a div with role="button" and a span containing "Event Log". const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index a27f818..68a70d1 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -32,8 +32,13 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ apiKey: z.string().nullable(), apiKeyId: z.string().nullable(), groupCredential: z.string().min(1, 'groupCredential must be non-empty'), - shareCredentials: z.array(z.string().min(1, 'share credential must be non-empty')), - groupPubkeyHex: z.string(), + shareCredentials: z + .array(z.string().min(1, 'share credential must be non-empty')) + .nonempty('shareCredentials must contain at least one credential'), + groupPubkeyHex: z + .string() + .min(1, 'groupPubkeyHex must be non-empty') + .regex(/^([0-9a-fA-F]+)$/, 'groupPubkeyHex must be a hex string'), adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index 7310f76..e6a17e8 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -4,6 +4,12 @@ import path from 'path'; import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; +function normalizeOptionalEnv(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + function loadFixtureTestKeysetSecret(): string | undefined { const fixturePath = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -23,8 +29,8 @@ function loadFixtureTestKeysetSecret(): string | undefined { } const TEST_KEYSET_SECRET = - process.env.TEST_KEYSET_SECRET ?? - process.env.TEST_NSEC_HEX ?? + normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? + normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? loadFixtureTestKeysetSecret(); if (!TEST_KEYSET_SECRET) { throw new Error( From 61bd400d6ada15f63cb077e0eca5685b53a79747 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Wed, 25 Feb 2026 19:21:36 -0600 Subject: [PATCH 41/69] test: clarify configure navigation e2e flow --- tests/e2e/specs/08-ui.e2e.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts index 1ce7913..92ac82b 100644 --- a/tests/e2e/specs/08-ui.e2e.ts +++ b/tests/e2e/specs/08-ui.e2e.ts @@ -53,19 +53,15 @@ test.describe('UI – Authenticated app', () => { await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); }); - test('Configure tab is accessible', async ({ page }) => { - const configureTab = page - .locator('[role="tab"]:has-text("Configure"), button:has-text("Configure"), a:has-text("Configure")') - .first(); - await expect(configureTab).toBeVisible({ timeout: 8_000 }); - await configureTab.click(); - // After clicking, the configure panel content should appear + test('Back to Configure button navigates to configuration page', async ({ page }) => { + // In signer view, config form copy should not be visible yet. + await expect(page.locator('body')).not.toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); + + const backToConfigure = page.locator('button:has-text("Back to Configure")').first(); + await expect(backToConfigure).toBeVisible({ timeout: 8_000 }); + await backToConfigure.click(); await page.waitForLoadState('networkidle'); - // Scope to likely configure containers to avoid matching unrelated page inputs. - const configContent = page.locator( - '[role="tabpanel"] input, [role="tabpanel"] textarea, [role="tabpanel"] [data-testid*="cred"], [data-testid*="config"] input, [data-testid*="config"] textarea, [id*="config"] input, [id*="config"] textarea' - ).first(); - await expect(configContent).toBeVisible({ timeout: 8_000 }); + await expect(page.locator('body')).toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); }); test('API Keys tab is accessible', async ({ page }) => { From 3b8c8257886653bb53d9047a7245c3bfd18d4427 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 11:26:29 -0600 Subject: [PATCH 42/69] fix: address review findings across routes, docs, and e2e --- .github/workflows/release.yml | 4 +- docs/openapi/README.md | 6 +- docs/openapi/openapi.json | 48 ++-- docs/openapi/openapi.yaml | 19 +- frontend/components/Configure.tsx | 35 ++- frontend/components/Signer.tsx | 24 +- frontend/components/nip46/RelaySettings.tsx | 8 +- frontend/components/nip46/Requests.tsx | 22 +- frontend/components/ui/card.tsx | 2 +- frontend/components/ui/collapsible.tsx | 3 +- .../components/ui/input-with-validation.tsx | 3 +- frontend/components/ui/peer-list.tsx | 8 +- frontend/components/ui/tooltip.tsx | 41 ++- llm/implementation/e2e-smoke-tests.md | 2 +- playwright-report/index.html | 85 ------ scripts/patch-zod-compat.mjs | 7 +- src/class/relay.test.ts | 14 + src/config/crypto.ts | 4 +- src/db/migrator.ts | 2 +- src/routes/admin.ts | 8 +- src/routes/auth.ts | 20 +- src/routes/env.ts | 9 +- src/routes/index.ts | 244 +++++++++--------- src/routes/nip04.ts | 4 + src/routes/nip44.ts | 12 +- src/routes/nip46.ts | 83 +++--- src/routes/onboarding.ts | 5 +- src/routes/status.ts | 2 +- src/routes/user.ts | 4 +- src/routes/utils.test.ts | 20 ++ src/routes/utils.ts | 7 +- src/server.ts | 8 +- src/utils/rate-limiter.ts | 10 +- tests/e2e/cosigner.mjs | 11 +- tests/e2e/global-setup.ts | 29 ++- tests/e2e/specs/02-status-peers.e2e.ts | 15 +- tests/e2e/specs/04-sign.e2e.ts | 7 +- tests/e2e/specs/06-event-log.e2e.ts | 5 +- tests/routes/admin.whoami.session.spec.ts | 4 +- tests/routes/env.db-mode.spec.ts | 2 +- tests/routes/helpers/script-runner.ts | 5 +- 41 files changed, 472 insertions(+), 379 deletions(-) delete mode 100644 playwright-report/index.html diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a42ac50..8163bb7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,8 +1,6 @@ name: Release on: - push: - branches: [ master ] workflow_dispatch: inputs: version: @@ -197,7 +195,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: master + ref: refs/tags/${{ needs.release.outputs.new_version }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/docs/openapi/README.md b/docs/openapi/README.md index d27d77e..b9fecba 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -4,8 +4,8 @@ This directory contains the comprehensive OpenAPI 3.1 specification for the Iglo ## Files -- **`openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format -- **`openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec +- **`docs/openapi/openapi.yaml`** - Complete OpenAPI 3.1 specification in YAML format +- **`docs/openapi/openapi.json`** - Bundled JSON representation generated from the YAML spec - **`README.md`** - This documentation file ## Accessing the Documentation @@ -102,7 +102,7 @@ This ensures the YAML syntax is correct and the specification is well-formed. When adding or modifying API endpoints: -1. Update the corresponding section in `openapi/openapi.yaml` +1. Update the corresponding section in `docs/openapi/openapi.yaml` 2. Add/update request and response schemas 3. Include relevant examples 4. Validate the specification: `bun run docs:validate` diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 0bbca29..2ac19fa 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -419,22 +419,14 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest", + "description": "Bad request", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/AuthStatus" + "$ref": "#/components/schemas/ErrorResponse" }, "example": { - "enabled": true, - "methods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ], - "rateLimiting": true, - "sessionTimeout": 3600 + "error": "Invalid authentication status request" } } } @@ -3008,14 +3000,13 @@ "methods": { "type": "array", "items": { - "type": "string", - "enum": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] - }, + "type": "string", + "enum": [ + "api-key", + "basic-auth", + "session" + ] + }, "description": "Available authentication methods" }, "rateLimiting": { @@ -3526,7 +3517,7 @@ "type": "object", "description": "Client-supplied fields when creating or updating a NIP‑46 session", "properties": { - "client_pubkey": { + "pubkey": { "type": "string", "description": "Client public key (hex encoded)" }, @@ -3554,7 +3545,7 @@ } }, "required": [ - "client_pubkey" + "pubkey" ] }, "Nip46Session": { @@ -4073,13 +4064,12 @@ ] }, "example": { - "error": "Authentication required", - "authMethods": [ - "api-key", - "bearer", - "basic-auth", - "session" - ] + "error": "Authentication required", + "authMethods": [ + "api-key", + "basic-auth", + "session" + ] } } } @@ -4189,4 +4179,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index db5e2ca..b654aad 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -327,16 +327,13 @@ paths: schema: $ref: '#/components/schemas/AuthStatus' '400': - $ref: '#/components/responses/BadRequest' + description: Bad request content: application/json: schema: - $ref: '#/components/schemas/AuthStatus' + $ref: '#/components/schemas/ErrorResponse' example: - enabled: true - methods: ["api-key", "bearer", "basic-auth", "session"] - rateLimiting: true - sessionTimeout: 3600 + error: "Invalid authentication status request" /api/auth/login: post: @@ -1960,7 +1957,7 @@ components: type: array items: type: string - enum: ["api-key", "bearer", "basic-auth", "session"] + enum: ["api-key", "basic-auth", "session"] description: Available authentication methods rateLimiting: type: boolean @@ -2323,7 +2320,7 @@ components: type: object description: Client-supplied fields when creating or updating a NIP‑46 session properties: - client_pubkey: + pubkey: type: string description: Client public key (hex encoded) status: @@ -2338,7 +2335,7 @@ components: description: Preferred relays for the session policy: $ref: '#/components/schemas/Nip46Policy' - required: [client_pubkey] + required: [pubkey] Nip46Session: type: object @@ -2631,7 +2628,7 @@ components: type: string example: error: "Authentication required" - authMethods: ["api-key", "bearer", "basic-auth", "session"] + authMethods: ["api-key", "basic-auth", "session"] InternalServerError: description: Internal server error @@ -2720,4 +2717,4 @@ tags: - name: Onboarding description: First-run onboarding and admin validation (database mode) - name: Event Log - description: Persisted UI event log endpoints (database mode only) \ No newline at end of file + description: Persisted UI event log endpoints (database mode only) diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index 7ed4967..c7f9f32 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -556,10 +556,28 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav setIsGenerating(true); try { + const resolveRelaysToSave = (): string[] => { + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays; + } + if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) { + try { + const parsed = JSON.parse(advancedSettings.RELAYS); + if (Array.isArray(parsed) && parsed.every(relay => typeof relay === 'string') && parsed.length > 0) { + return parsed; + } + } catch { + // fall back to default relay list below + } + } + return ["wss://relay.primal.net"]; + }; + // Save credentials based on mode if (isHeadlessMode) { // Headless mode - save to env - await fetch('/api/env', { + const relaysToSave = resolveRelaysToSave(); + const response = await fetch('/api/env', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -569,16 +587,19 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav SHARE_CRED: share, GROUP_CRED: groupCredential, GROUP_NAME: keysetName, - // Ensure we have at least one valid relay for the server to use - RELAYS: JSON.stringify(["wss://relay.primal.net"]) + RELAYS: JSON.stringify(relaysToSave) }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save headless credentials (${response.status})`); + } } else { // Database mode - save to user credentials // Preserve existing relays or use default if none exist - const relaysToSave = existingRelays || ["wss://relay.primal.net"]; + const relaysToSave = resolveRelaysToSave(); - await fetch('/api/user/credentials', { + const response = await fetch('/api/user/credentials', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -591,6 +612,10 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav relays: relaysToSave }) }); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw new Error(detail || `Failed to save credentials (${response.status})`); + } } setHasExistingCredentials(true); diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index e219b56..0b2eea7 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -382,27 +382,19 @@ const Signer = forwardRef(({ initialData, authHeaders const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = `${protocol}//${window.location.host}/api/events`; - // Add authentication parameters for WebSocket connection - // Since WebSocket doesn't support custom headers during upgrade, - // we need to pass auth info via URL parameters - const params = new URLSearchParams(); - - // Check if we have auth headers and convert them to URL params + // Avoid exposing long-lived credentials in URL query params. + // Prefer WebSocket subprotocol auth hints supported by the backend. + const protocols: string[] = []; const currentAuth = authHeadersRef.current; if (currentAuth['X-API-Key']) { - params.set('apiKey', currentAuth['X-API-Key']); + protocols.push(`api-key.${currentAuth['X-API-Key']}`); } else if (currentAuth['X-Session-ID']) { - params.set('sessionId', currentAuth['X-Session-ID']); + protocols.push(`session.${currentAuth['X-Session-ID']}`); } else if (currentAuth['Authorization'] && currentAuth['Authorization'].startsWith('Basic ')) { - // For basic auth, we'll rely on cookies or handle it server-side - // The server should accept the connection if the user is already authenticated - } - - if (params.toString()) { - wsUrl += '?' + params.toString(); + // For basic auth, rely on existing browser credentials/cookies. } - - ws = new WebSocket(wsUrl); + + ws = protocols.length > 0 ? new WebSocket(wsUrl, protocols) : new WebSocket(wsUrl); ws.onopen = () => { isConnecting = false; diff --git a/frontend/components/nip46/RelaySettings.tsx b/frontend/components/nip46/RelaySettings.tsx index a3708f0..e4b8206 100644 --- a/frontend/components/nip46/RelaySettings.tsx +++ b/frontend/components/nip46/RelaySettings.tsx @@ -75,7 +75,13 @@ export function RelaySettings({ relays, onAdd, onRemove, loading = false, saving size="sm" icon={} tooltip="Remove relay" - onClick={() => onRemove(relay)} + onClick={async () => { + try { + await onRemove(relay) + } catch (error) { + console.error('[RelaySettings] Failed to remove relay:', error) + } + }} disabled={saving} /> diff --git a/frontend/components/nip46/Requests.tsx b/frontend/components/nip46/Requests.tsx index 17d752c..0cfe2c8 100644 --- a/frontend/components/nip46/Requests.tsx +++ b/frontend/components/nip46/Requests.tsx @@ -29,6 +29,7 @@ interface ParsedRequest { eventKind: number | null eventTemplate: Record | null contentPreview: string | null + contentTruncated: boolean } const DEFAULT_POLICY: PermissionPolicy = { methods: {}, kinds: {} } @@ -43,6 +44,14 @@ const formatTimestamp = (value: string) => { return Number.isNaN(date.getTime()) ? 'N/A' : date.toLocaleString() } +const sanitizePreview = (value: string): string => { + return value + .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') + .replace(/[\u202A-\u202E\u2066-\u2069]/g, '') + .replace(/\s+/g, ' ') + .trim() +} + const parseRequest = (record: Nip46RequestApi): ParsedRequest => { let method = record.method let params: any[] = [] @@ -82,9 +91,11 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { } } - const contentPreview = eventTemplate && typeof eventTemplate.content === 'string' - ? eventTemplate.content.trim().slice(0, 160) + const sanitizedContent = eventTemplate && typeof eventTemplate.content === 'string' + ? sanitizePreview(eventTemplate.content) : null + const contentPreview = sanitizedContent ? sanitizedContent.slice(0, 160) : null + const contentTruncated = !!sanitizedContent && sanitizedContent.length > 160 return { record, @@ -96,7 +107,8 @@ const parseRequest = (record: Nip46RequestApi): ParsedRequest => { sessionUrl, eventKind, eventTemplate, - contentPreview + contentPreview, + contentTruncated } } @@ -197,7 +209,7 @@ export function Requests({
{parsedRequests.map(entry => { - const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview } = entry + const { record, method, sessionName, sessionImage, sessionUrl, eventKind, eventTemplate, params, contentPreview, contentTruncated } = entry const policy = policies[record.session_pubkey] ?? DEFAULT_POLICY const methodAllowed = policy.methods?.[method] === true const wildcardKind = policy.kinds?.['*'] === true @@ -276,7 +288,7 @@ export function Requests({
{contentPreview ? ( -
{contentPreview}{eventTemplate?.content && eventTemplate.content.length > 160 ? '…' : ''}
+
{contentPreview}{contentTruncated ? '…' : ''}
) : null}
diff --git a/frontend/components/ui/card.tsx b/frontend/components/ui/card.tsx index 7881a5c..5588b38 100644 --- a/frontend/components/ui/card.tsx +++ b/frontend/components/ui/card.tsx @@ -29,7 +29,7 @@ const CardHeader = React.forwardRef< CardHeader.displayName = "CardHeader" const CardTitle = React.forwardRef< - HTMLParagraphElement, + HTMLHeadingElement, React.HTMLAttributes >(({ className, ...props }, ref) => (

= ({ )} onClick={toggleExpanded} role="button" + aria-expanded={isExpanded} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -72,4 +73,4 @@ const Collapsible: React.FC = ({ ); }; -export { Collapsible }; \ No newline at end of file +export { Collapsible }; diff --git a/frontend/components/ui/input-with-validation.tsx b/frontend/components/ui/input-with-validation.tsx index 6866ca2..8401b27 100644 --- a/frontend/components/ui/input-with-validation.tsx +++ b/frontend/components/ui/input-with-validation.tsx @@ -37,6 +37,7 @@ const InputWithValidation: React.FC = ({ id={inputId} value={value} onChange={(e) => onChange(e.target.value)} + required={isRequired} className={cn( "bg-gray-800/50 border-gray-700/50 text-blue-300 py-2 text-sm w-full", hasError && "border-red-500", @@ -51,4 +52,4 @@ const InputWithValidation: React.FC = ({ ); }; -export { InputWithValidation }; \ No newline at end of file +export { InputWithValidation }; diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 7a1471f..1d8f717 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef, useId } from 'react'; import { Button } from './button'; import { IconButton } from './icon-button'; import { Badge, type BadgeProps } from './badge'; @@ -179,6 +179,7 @@ const PeerList: React.FC = ({ const [policyPeerErrors, setPolicyPeerErrors] = useState>(new Map()); const hasUserToggledRef = useRef(false); const panelRef = useRef(null); + const panelId = useId(); useEffect(() => { if (defaultExpanded && !hasUserToggledRef.current) { @@ -627,6 +628,7 @@ const PeerList: React.FC = ({ onClick={handleToggle} role="button" aria-expanded={isExpanded} + aria-controls={panelId} tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { @@ -698,6 +700,9 @@ const PeerList: React.FC = ({ {/* Collapsible Content */}
= ({ width="w-72" triggerClassName="cursor-help" focusable + ariaLabel="Policy controls help" trigger={} content={
diff --git a/frontend/components/ui/tooltip.tsx b/frontend/components/ui/tooltip.tsx index 543eca3..58d9059 100644 --- a/frontend/components/ui/tooltip.tsx +++ b/frontend/components/ui/tooltip.tsx @@ -2,33 +2,48 @@ import React, { useState, ReactNode, useRef, useEffect, useCallback, useId } fro import { createPortal } from 'react-dom'; import { cn } from "../../lib/utils"; -interface TooltipProps { +interface TooltipSharedProps { trigger: ReactNode; content: ReactNode; className?: string; position?: 'top' | 'right' | 'bottom' | 'left'; width?: string; triggerClassName?: string; - focusable?: boolean; - ariaLabel?: string; } -const Tooltip: React.FC = ({ - trigger, - content, - className, - position = 'left', - width = 'w-72', - triggerClassName, - focusable = false, - ariaLabel, -}) => { +type TooltipProps = + | (TooltipSharedProps & { + focusable: true; + ariaLabel: string; + }) + | (TooltipSharedProps & { + focusable?: false; + ariaLabel?: string; + }); + +const Tooltip: React.FC = (props) => { + const { + trigger, + content, + className, + position = 'left', + width = 'w-72', + triggerClassName, + } = props; + const focusable = props.focusable ?? false; + const ariaLabel = props.ariaLabel; const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number }>({ top: 0, left: 0 }); const tooltipId = useId(); const triggerRef = useRef(null); const tooltipRef = useRef(null); + useEffect(() => { + if (focusable && !ariaLabel) { + console.error('[Tooltip] focusable tooltips require ariaLabel for accessibility.'); + } + }, [focusable, ariaLabel]); + const updatePosition = useCallback(() => { if (typeof window === 'undefined' || !triggerRef.current || !tooltipRef.current) { return; diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md index 0bb18d7..19fcdc4 100644 --- a/llm/implementation/e2e-smoke-tests.md +++ b/llm/implementation/e2e-smoke-tests.md @@ -1,7 +1,7 @@ # E2E Smoke Test Suite (Playwright – DB Mode) Last verified: 2026-02-20 -Test count: 62 (54 API + 8 UI) — all passing +Status snapshot: all Playwright smoke tests passing as of the verification date above. For current counts, run `npx playwright test --list`. ## Purpose diff --git a/playwright-report/index.html b/playwright-report/index.html deleted file mode 100644 index 41e3ff2..0000000 --- a/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/scripts/patch-zod-compat.mjs b/scripts/patch-zod-compat.mjs index 5006721..00736a8 100644 --- a/scripts/patch-zod-compat.mjs +++ b/scripts/patch-zod-compat.mjs @@ -9,7 +9,12 @@ const ZOD_DIR = join(PROJECT_ROOT, 'node_modules', 'zod') const NOSTR_SCHEMA_DIR = join(PROJECT_ROOT, 'node_modules', '@cmdcode', 'nostr-connect', 'dist', 'schema') function ensureFile(path, content) { - const current = readFileSync(path, 'utf8') + let current = '' + try { + current = readFileSync(path, 'utf8') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } if (current !== content) { writeFileSync(path, content) } diff --git a/src/class/relay.test.ts b/src/class/relay.test.ts index 2c9db2a..58877f2 100644 --- a/src/class/relay.test.ts +++ b/src/class/relay.test.ts @@ -68,6 +68,20 @@ describe('NostrRelay REQ handling', () => { expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); }); + it('rejects REQ with no filters', () => { + const relay = new NostrRelay({ info: false, debug: false }); + const socket = createFakeSocket(); + const ws = asHandlerSocket(socket); + const handler = relay.handler(); + + handler.open?.(ws); + handler.message?.(ws, JSON.stringify(['REQ', 'sub-empty-no-filters'])); + + expect(relay.subs.size).toBe(0); + const messages = decodeSent(socket); + expect(messages).toContainEqual(['NOTICE', '', 'REQ requires at least one filter']); + }); + it('accepts canonical multi-filter REQ payloads and creates a subscription', () => { const relay = new NostrRelay({ info: false, debug: false }); const socket = createFakeSocket(); diff --git a/src/config/crypto.ts b/src/config/crypto.ts index d72e59b..56d6f36 100644 --- a/src/config/crypto.ts +++ b/src/config/crypto.ts @@ -5,7 +5,7 @@ // PBKDF2 Configuration for Key Derivation export const PBKDF2_CONFIG = { - ITERATIONS: 200000, // Number of iterations (higher = more secure but slower) + ITERATIONS: 600000, // OWASP-aligned baseline for PBKDF2-HMAC-SHA256 KEY_LENGTH: 32, // 256 bits ALGORITHM: 'sha256', // Hash algorithm } as const; @@ -60,4 +60,4 @@ export function isPasswordValid(pwd: string): boolean { return false; } return VALIDATION.PASSWORD_REGEX.test(pwd); -} \ No newline at end of file +} diff --git a/src/db/migrator.ts b/src/db/migrator.ts index b0d2f9e..987516c 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -28,7 +28,7 @@ export function runMigrations(migrationsDirRel = 'src/db/migrations', opts?: { s // Security: Ensure migrations directory is within project boundaries const projectRoot = path.resolve(process.cwd()) - if (!dir.startsWith(projectRoot + path.sep) && dir !== projectRoot) { + if (!dir.startsWith(projectRoot + path.sep)) { throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) } diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 2a8e4e0..5f39256 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -126,13 +126,17 @@ export async function handleAdminRoute( // Check rate limit before admin authentication to prevent brute force attacks const rate = await checkRateLimit(req, 'auth', { clientIp: _context.clientIp }); if (!rate.allowed) { + const fallbackRetryAfterSeconds = Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString(); + const retryAfterSeconds = typeof rate.resetAt === 'number' + ? Math.max(1, Math.ceil((rate.resetAt - Date.now()) / 1000)).toString() + : fallbackRetryAfterSeconds; return Response.json( { error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + 'Retry-After': retryAfterSeconds } } ); @@ -272,7 +276,7 @@ export async function handleAdminRoute( createdAt: key.createdAt, updatedAt: key.updatedAt, lastUsedAt: key.lastUsedAt, - lastUsedIp: key.lastUsedIp, + lastUsedIp: null, revokedAt: key.revokedAt, revokedReason: key.revokedReason, createdByUserId: key.createdByUserId, diff --git a/src/routes/auth.ts b/src/routes/auth.ts index d496161..8e910a9 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -453,7 +453,7 @@ export async function checkRateLimit( req: Request, bucket: string = 'auth', opts?: { windowMs?: number; max?: number; clientIp?: string } -): Promise<{ allowed: boolean; remaining: number }> { +): Promise<{ allowed: boolean; remaining: number; resetAt?: number }> { if (!AUTH_CONFIG.RATE_LIMIT_ENABLED) { return { allowed: true, remaining: AUTH_CONFIG.RATE_LIMIT_MAX }; } @@ -469,7 +469,8 @@ export async function checkRateLimit( return { allowed: result.allowed, - remaining: result.remaining + remaining: result.remaining, + resetAt: result.resetAt }; } @@ -1009,7 +1010,7 @@ export async function handleLogin(req: Request): Promise { return Response.json({ success: false, error: 'Database temporarily unavailable. Please try again.' - }, { status: 503 }); // 503 Service Unavailable + }, { status: 503, headers: baseHeaders }); // 503 Service Unavailable } // For unexpected errors, log but don't expose details @@ -1099,6 +1100,19 @@ export function handleLogout(req: Request): Response { if (req.method === 'OPTIONS') { return new Response(null, { status: 204, headers }); } + + if (req.method !== 'POST') { + return Response.json( + { error: 'Method not allowed' }, + { + status: 405, + headers: { + ...headers, + 'Allow': 'POST, OPTIONS' + } + } + ); + } const sessionId = req.headers.get('x-session-id') || extractSessionFromCookie(req); diff --git a/src/routes/env.ts b/src/routes/env.ts index 20667f4..861f17c 100644 --- a/src/routes/env.ts +++ b/src/routes/env.ts @@ -94,8 +94,13 @@ export async function handleEnvRoute(req: Request, url: URL, context: Privileged // Resolve authenticated DB user id (database mode only) const authenticatedNumericUserId = (() => { if (HEADLESS || !auth?.authenticated) return null; - if (typeof auth.userId === 'number') return BigInt(auth.userId); - if (typeof auth.userId === 'string' && /^\d+$/.test(auth.userId)) return BigInt(auth.userId); + if (typeof auth.userId === 'number') { + if (!Number.isInteger(auth.userId) || auth.userId <= 0) return null; + return BigInt(auth.userId); + } + if (typeof auth.userId === 'string' && /^[1-9]\d*$/.test(auth.userId)) { + return BigInt(auth.userId); + } return null; })(); const isRoleAdmin = await (async () => { diff --git a/src/routes/index.ts b/src/routes/index.ts index 8183c72..5dac5cd 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -190,147 +190,143 @@ export async function handleRequest( // Admin endpoints have their own ADMIN_SECRET authentication const isAdminEndpoint = url.pathname.startsWith('/api/admin'); - // Authentication check for API endpoints (skip public endpoints, status, and admin) - if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { - const authResult = await authenticate(req); - - if (authResult.rateLimited) { - const response = Response.json({ - error: 'Rate limit exceeded. Try again later.' - }, { - status: 429, - headers: { - ...headers, - 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() - } - }); - finalizeAuth(); - return response; - } - - if (!authResult.authenticated) { - // Don't set WWW-Authenticate header to avoid browser's native auth dialog - // The frontend will handle authentication through its own UI - const response = Response.json({ - error: authResult.error || 'Authentication required', - authMethods: getAuthStatus() - }, { - status: 401, - headers - }); - finalizeAuth(); - return response; - } - - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword - }); - } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { - // Special handling for /api/status: attempt authentication if headers are present - // but don't require it (allow unauthenticated health checks) - try { + try { + // Authentication check for API endpoints (skip public endpoints, status, and admin) + if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && !isAdminEndpoint) { const authResult = await authenticate(req); - // Only use auth info if authentication actually succeeded (not rate limited or failed) - if (authResult.authenticated && !authResult.rateLimited) { - // Create auth info with secure ephemeral storage for secrets - authInfo = createRequestAuth({ - userId: authResult.userId, - authenticated: true, - derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, - sessionId: authResult.sessionId, - hasPassword: authResult.hasPassword + if (authResult.rateLimited) { + return Response.json({ + error: 'Rate limit exceeded. Try again later.' + }, { + status: 429, + headers: { + ...headers, + 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() + } }); } - // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) - } catch (error) { - // If authentication throws an error, allow unauthenticated access - // Authentication attempt failed, allowing unauthenticated access for health checks - } - } - - // Note: Authentication is now handled above for all non-public API endpoints - - // Handle user routes (database mode only) - if (!HEADLESS && url.pathname.startsWith('/api/user')) { - const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); - if (userResult) { - finalizeAuth(); - return userResult; - } - } - - // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, - // but when a valid session exists for an admin user we allow that too. - if (!HEADLESS && url.pathname.startsWith('/api/admin')) { - // Attempt optional authentication for admin endpoints to support session-admin access. - // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. - if (AUTH_CONFIG.ENABLED && !authInfo) { + + if (!authResult.authenticated) { + // Don't set WWW-Authenticate header to avoid browser's native auth dialog + // The frontend will handle authentication through its own UI + return Response.json({ + error: authResult.error || 'Authentication required', + authMethods: getAuthStatus() + }, { + status: 401, + headers + }); + } + + authInfo = createRequestAuth({ + userId: authResult.userId, + authenticated: true, + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword + }); + } else if (isStatusEndpoint && AUTH_CONFIG.ENABLED) { + // Special handling for /api/status: attempt authentication if headers are present + // but don't require it (allow unauthenticated health checks) try { - const adminAuth = await authenticate(req); - if (adminAuth.authenticated && !adminAuth.rateLimited) { + const authResult = await authenticate(req); + + // Only use auth info if authentication actually succeeded (not rate limited or failed) + if (authResult.authenticated && !authResult.rateLimited) { + // Create auth info with secure ephemeral storage for secrets authInfo = createRequestAuth({ - userId: adminAuth.userId, + userId: authResult.userId, authenticated: true, - derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, - sessionId: adminAuth.sessionId, - hasPassword: adminAuth.hasPassword + derivedKey: authResult.derivedKey ? authResult.derivedKey : undefined, + sessionId: authResult.sessionId, + hasPassword: authResult.hasPassword }); } - } catch {} + // If authentication failed or was rate limited, authInfo remains null (unauthenticated access) + } catch (error) { + // If authentication throws an error, allow unauthenticated access + // Authentication attempt failed, allowing unauthenticated access for health checks + } } - const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); - if (adminResult) { - finalizeAuth(); - return adminResult; + // Note: Authentication is now handled above for all non-public API endpoints + + // Handle user routes (database mode only) + if (!HEADLESS && url.pathname.startsWith('/api/user')) { + const userResult = await handleUserRoute(req, url, privilegedContext, authInfo); + if (userResult) { + return userResult; + } } - } - - // Handle privileged routes separately - if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { - const result = await handleEnvRoute(req, url, privilegedContext, authInfo); - if (result) { - finalizeAuth(); - return result; + + // Handle admin routes (database mode only). Admin routes primarily use ADMIN_SECRET, + // but when a valid session exists for an admin user we allow that too. + if (!HEADLESS && url.pathname.startsWith('/api/admin')) { + // Attempt optional authentication for admin endpoints to support session-admin access. + // Do not enforce auth result here; handleAdminRoute will decide based on ADMIN_SECRET or session. + if (AUTH_CONFIG.ENABLED && !authInfo) { + try { + const adminAuth = await authenticate(req); + if (adminAuth.authenticated && !adminAuth.rateLimited) { + authInfo = createRequestAuth({ + userId: adminAuth.userId, + authenticated: true, + derivedKey: adminAuth.derivedKey ? adminAuth.derivedKey : undefined, + sessionId: adminAuth.sessionId, + hasPassword: adminAuth.hasPassword + }); + } + } catch {} + } + + const adminResult = await handleAdminRoute(req, url, baseContext, authInfo); + if (adminResult) { + return adminResult; + } + } + + // Handle privileged routes separately + if (needsPrivilegedAccess && url.pathname.startsWith('/api/env')) { + const result = await handleEnvRoute(req, url, privilegedContext, authInfo); + if (result) { + return result; + } } - } - if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { - const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); - if (nip46Result) { - finalizeAuth(); - return nip46Result; + if (!HEADLESS && url.pathname.startsWith('/api/nip46/')) { + const nip46Result = await handleNip46Route(req, url, privilegedContext, authInfo); + if (nip46Result) { + return nip46Result; + } } - } - // Try each non-privileged route handler in order - // Note: These handlers now accept auth as an optional parameter - const routeHandlers = [ - handleStatusRoute, // Allow unauthenticated for health checks - handleUpdateRoute, - handleEventLogRoute, - handlePeersRoute, - handleSignRoute, - handleNip44Route, - handleNip04Route, - handleRecoveryRoute, - ]; + // Try each non-privileged route handler in order + // Note: These handlers now accept auth as an optional parameter + const routeHandlers = [ + handleStatusRoute, // Allow unauthenticated for health checks + handleUpdateRoute, + handleEventLogRoute, + handlePeersRoute, + handleSignRoute, + handleNip44Route, + handleNip04Route, + handleRecoveryRoute, + ]; - for (const handler of routeHandlers) { - const result = await handler(req, url, context, authInfo); - if (result) { - finalizeAuth(); - return result; + for (const handler of routeHandlers) { + const result = await handler(req, url, context, authInfo); + if (result) { + return result; + } } - } - // If no route matched, return 404 - const notFound = new Response('Not Found', { status: 404 }); - finalizeAuth(); - return notFound; + // If no route matched, return 404 + if (url.pathname.startsWith('/api/')) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }); + } + return new Response('Not Found', { status: 404 }); + } finally { + finalizeAuth(); + } } diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index 9592a0e..135543c 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -79,6 +79,10 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon if (!isContentLengthWithin(req, DEFAULT_MAX_JSON_BODY)) { return Response.json({ error: 'Request too large' }, { status: 413, headers }) } + const authContext = _auth ?? context.auth + if (!authContext?.authenticated) { + return Response.json({ error: 'Unauthorized' }, { status: 401, headers }) + } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }) // Separate bucket for e2e crypto ops diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index 3947620..b0d71ef 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -65,17 +65,15 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon const mode = url.pathname.endsWith('/encrypt') ? 'encrypt' : url.pathname.endsWith('/decrypt') ? 'decrypt' : null; if (!mode) return Response.json({ error: 'Unknown operation' }, { status: 404, headers }); - // Platform-agnostic hex to Uint8Array conversion - const hexBytes = secretHex.match(/.{1,2}/g); - if (!hexBytes) { - throw new Error('Invalid hex string format'); + const key = Uint8Array.from(Buffer.from(secretHex, 'hex')); + if (key.length !== 32) { + throw new Error('Invalid shared secret length'); } - const key = new Uint8Array(hexBytes.map(byte => parseInt(byte, 16))); if (mode === 'encrypt') { - const ciphertext = await nip44.encrypt(content, key); + const ciphertext = nip44.encrypt(content, key); return Response.json({ result: ciphertext }, { status: 200, headers }); } else { - const plaintext = await nip44.decrypt(content, key); + const plaintext = nip44.decrypt(content, key); return Response.json({ result: plaintext }, { status: 200, headers }); } } catch (e: any) { diff --git a/src/routes/nip46.ts b/src/routes/nip46.ts index 75f1d6a..88c7a5d 100644 --- a/src/routes/nip46.ts +++ b/src/routes/nip46.ts @@ -192,10 +192,6 @@ export async function handleNip46Route( return Response.json({ error: 'NIP-46 persistence unavailable in headless mode' }, { status: 404 }) } - // Ensure database is initialized before processing any NIP46 requests - // This prevents race conditions where routes are accessed before migrations complete - await initializeNip46DB() - const corsHeaders = getSecureCorsHeaders(req) const mergedVary = mergeVaryHeaders(corsHeaders) const headers = { @@ -206,6 +202,16 @@ export async function handleNip46Route( 'Vary': mergedVary, } + // Ensure database is initialized before processing any NIP46 requests. + // This prevents race conditions where routes are accessed before migrations complete. + try { + await initializeNip46DB() + } catch (error) { + console.error('[NIP46] Failed to initialize DB:', error) + const message = error instanceof Error ? error.message : 'Failed to initialize NIP-46 database' + return Response.json({ error: 'DB_INIT_FAILED', message }, { status: 500, headers }) + } + if (req.method === 'OPTIONS') return new Response(null, { status: 200, headers }) // Require authenticated DB user @@ -336,33 +342,33 @@ export async function handleNip46Route( const result = typeof body?.result === 'string' ? body.result : null const errorMessage = typeof body?.error === 'string' ? body.error : null - const policyPatch = parsePolicyPatch(body?.policy) - let existingRecord = policyPatch ? getNip46RequestById(id) : null - if (policyPatch) { - if (!existingRecord) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - const recordUserId = typeof existingRecord.user_id === 'bigint' - ? existingRecord.user_id.toString() - : String(existingRecord.user_id) - const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) - if (recordUserId !== requestUserId) { - return Response.json({ error: 'Request not found' }, { status: 404, headers }) - } - - const session = getSession(userId, existingRecord.session_pubkey) - if (!session) { - return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) - } - - try { - const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) - updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) - } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update policy' - return Response.json({ error: message }, { status: 400, headers }) - } - } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + + const policyPatch = parsePolicyPatch(body?.policy) + if (policyPatch) { + const session = getSession(userId, existingRecord.session_pubkey) + if (!session) { + return Response.json({ error: 'Session not found for policy update' }, { status: 404, headers }) + } + + try { + const mergedPolicy = applyPolicyPatch(session.policy, policyPatch) + updatePolicy(userId, existingRecord.session_pubkey, mergedPolicy) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to update policy' + return Response.json({ error: message }, { status: 400, headers }) + } + } const record = updateNip46RequestStatus(id, status, { result, error: errorMessage }) if (!record) { @@ -388,6 +394,18 @@ export async function handleNip46Route( return Response.json({ error: 'Field "id" is required' }, { status: 400, headers }) } + const existingRecord = getNip46RequestById(id) + if (!existingRecord) { + return Response.json({ error: 'Request not found' }, { status: 404, headers }) + } + const recordUserId = typeof existingRecord.user_id === 'bigint' + ? existingRecord.user_id.toString() + : String(existingRecord.user_id) + const requestUserId = typeof userId === 'bigint' ? userId.toString() : String(userId) + if (recordUserId !== requestUserId) { + return Response.json({ error: 'Forbidden' }, { status: 403, headers }) + } + deleteNip46Request(id) return Response.json({ ok: true }, { headers }) } @@ -603,6 +621,9 @@ export async function handleNip46Route( if (url.pathname.startsWith('/api/nip46/sessions/') && req.method === 'DELETE') { const pubkey = parsePubkeyFromPath(url.pathname) if (!pubkey || !isValidHex(pubkey)) return Response.json({ error: 'Invalid pubkey' }, { status: 400, headers }) + if (url.pathname !== `/api/nip46/sessions/${pubkey}`) { + return Response.json({ error: 'Not Found' }, { status: 404, headers }) + } const ok = deleteSession(userId, pubkey.toLowerCase()) return Response.json({ ok }, { headers }) } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index a3d6efa..a25d975 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -267,8 +267,9 @@ const UNIFORM_AUTH_ERROR = { error: 'Authentication failed' }; // - Lowercase letter // - Digit // - Special character (at least one of @$!%*?&, but allows any special chars) -// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])\S*$/; +// Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH. +// Whitespace is allowed and preserved by policy. +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/; /** * Validates the admin secret in a timing-safe manner diff --git a/src/routes/status.ts b/src/routes/status.ts index e70593b..2ca85b5 100644 --- a/src/routes/status.ts +++ b/src/routes/status.ts @@ -59,7 +59,7 @@ export async function handleStatusRoute(req: Request, url: URL, context: RouteCo // Lazy-load DB only in non-headless, authenticated path const { userHasStoredCredentials } = await import('../db/database.js'); // Convert to bigint for database operation - const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : parsedUserId; + const dbUserId = typeof parsedUserId === 'string' ? BigInt(parsedUserId) : BigInt(parsedUserId); hasStoredCredentials = userHasStoredCredentials(dbUserId); } } diff --git a/src/routes/user.ts b/src/routes/user.ts index 05ab10c..aa27cc9 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -285,11 +285,11 @@ export async function handleUserRoute( // Validate relays format if (body.relays === null || (Array.isArray(body.relays) && - body.relays.every((r: any) => typeof r === 'string'))) { + body.relays.every((r: any) => typeof r === 'string' && isValidWebSocketUrl(r)))) { updates.relays = body.relays; } else { return Response.json( - { error: 'Invalid relays format. Must be an array of strings or null.' }, + { error: 'Invalid relay URLs. Must use ws:// or wss://' }, { status: 400, headers } ); } diff --git a/src/routes/utils.test.ts b/src/routes/utils.test.ts index 13d1a98..cf0c64a 100644 --- a/src/routes/utils.test.ts +++ b/src/routes/utils.test.ts @@ -56,6 +56,12 @@ describe('getValidRelays', () => { }); }); + it('filters localhost subdomain relay when localhost relays are disallowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { + expect(getValidRelays('["ws://relay.localhost:18002"]', { fallbackToDefault: false })).toEqual([]); + }); + }); + it('filters IPv4-mapped IPv6 relay when localhost relays are disallowed', async () => { await withEnv('ALLOW_LOCALHOST_RELAY', 'false', () => { expect(getValidRelays('["ws://[::ffff:127.0.0.1]:18002"]', { fallbackToDefault: false })).toEqual([]); @@ -100,6 +106,8 @@ describe('normalizeRelayListForEcho', () => { expect( normalizeRelayListForEcho([ 'ws://127.0.0.1:18002', + 'ws://[::1]:18002', + 'ws://[::ffff:127.0.0.1]:18002', 'ws://localhost:18002', 'wss://relay.example.com' ]) @@ -118,4 +126,16 @@ describe('normalizeRelayListForEcho', () => { expect(normalizeRelayListForEcho(['ws://localhost:18002'])).toEqual(['ws://localhost:18002']); }); }); + + it('keeps IPv6 localhost relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::1]:18002'])).toEqual(['ws://[::1]:18002']); + }); + }); + + it('keeps IPv4-mapped IPv6 relay in echo list when explicitly allowed', async () => { + await withEnv('ALLOW_LOCALHOST_RELAY', 'true', () => { + expect(normalizeRelayListForEcho(['ws://[::ffff:127.0.0.1]:18002'])).toEqual(['ws://[::ffff:127.0.0.1]:18002']); + }); + }); }); diff --git a/src/routes/utils.ts b/src/routes/utils.ts index 767d268..9798f9b 100644 --- a/src/routes/utils.ts +++ b/src/routes/utils.ts @@ -64,7 +64,12 @@ function extractIpv4MappedIpv6(hostname: string): string | null { function isLoopbackRelayHost(hostname: string): boolean { let normalized = hostname.replace(/\.+$/, '').replace(/^\[(.*)\]$/, '$1').toLowerCase(); - if (normalized === 'localhost' || normalized === '::1' || normalized === '0:0:0:0:0:0:0:1') return true; + if ( + normalized === 'localhost' || + normalized.endsWith('.localhost') || + normalized === '::1' || + normalized === '0:0:0:0:0:0:0:1' + ) return true; const mappedIpv4 = extractIpv4MappedIpv6(normalized); if (mappedIpv4) { diff --git a/src/server.ts b/src/server.ts index d720965..5fdf3db 100644 --- a/src/server.ts +++ b/src/server.ts @@ -415,12 +415,14 @@ async function initializeDatabase(): Promise { } } -// Initialize database with single exit point -initializeDatabase().catch((err) => { +// Initialize database before starting relay/node setup +try { + await initializeDatabase(); +} catch (err) { console.error('❌ Fatal initialization error:'); console.error(' ', err instanceof Error ? err.message : String(err)); process.exit(1); -}); +} // Create the Nostr relay const relay = new NostrRelay(); diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 8334059..8670b23 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -182,7 +182,8 @@ export class PersistentRateLimiter { } } - throw new RateLimiterUnavailableError(); + // Defensive fallback for type completeness; loop paths above should always return or throw. + return this.checkMemoryLimit(identifier, config, Date.now()); } /** @@ -264,14 +265,13 @@ export class PersistentRateLimiter { if (this.db) { try { - this.db + const result = this.db .prepare('DELETE FROM rate_limits WHERE last_attempt < ?') .run(cutoff); // Only log if entries were deleted - const changes = this.db.query('SELECT changes() as c').get() as { c: number } | null; - if (changes && changes.c > 0) { - console.log(`[RateLimiter] Cleaned up ${changes.c} expired entries`); + if (result.changes > 0) { + console.log(`[RateLimiter] Cleaned up ${result.changes} expired entries`); } } catch (error) { console.error('[RateLimiter] Cleanup failed:', error); diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs index 1d608cd..9e655d7 100644 --- a/tests/e2e/cosigner.mjs +++ b/tests/e2e/cosigner.mjs @@ -63,6 +63,7 @@ function safeStringify(obj) { } let node; +let shuttingDown = false; try { node = createBifrostNode({ group: groupCred, @@ -74,7 +75,14 @@ try { console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); }); - node.on('closed', () => console.log('[cosigner] Node closed')); + node.on('closed', () => { + if (shuttingDown) { + console.log('[cosigner] Node closed'); + return; + } + console.error('[cosigner] Node closed unexpectedly'); + process.exit(1); + }); node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); node.on('message', (msg) => { @@ -106,6 +114,7 @@ try { } const shutdown = () => { + shuttingDown = true; try { node?.close?.(); } catch (e) { console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); } diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index c5347a2..c9b94b4 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -90,8 +90,18 @@ function requireNonEmptyString(value: string | undefined, errorMessage: string): return value; } +function validateTestNsecHex(raw: string): string { + const normalized = raw.trim(); + if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0 || normalized.length !== 64) { + throw new Error( + 'Invalid TEST_NSEC_HEX: expected a 32-byte private key encoded as exactly 64 hex characters.' + ); + } + return normalized.toLowerCase(); +} + // Defaults come from fixture for local CI; callers can still override via environment. -const TEST_NSEC_HEX = process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex; +const TEST_NSEC_HEX = validateTestNsecHex(process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex); const MISSING_SMOKE_CREDS_MESSAGE = 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; @@ -112,8 +122,19 @@ function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +function ensurePrivateDir(dirPath: string): void { + fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); + try { + fs.chmodSync(dirPath, 0o700); + } catch {} +} + function writeState(state: SmokeTestState): void { - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + ensurePrivateDir(path.dirname(STATE_FILE)); + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); + try { + fs.chmodSync(STATE_FILE, 0o600); + } catch {} process.env.SMOKE_STATE_FILE = STATE_FILE; } @@ -275,8 +296,8 @@ export default async function globalSetup(_config: FullConfig): Promise { console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); } } - fs.mkdirSync(TMP_DIR, { recursive: true }); - fs.mkdirSync(DB_PATH, { recursive: true }); + ensurePrivateDir(TMP_DIR); + ensurePrivateDir(DB_PATH); writeState(state); console.log('[setup] Generating FROSTR credentials...'); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts index cddaefb..7ebb7e7 100644 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ b/tests/e2e/specs/02-status-peers.e2e.ts @@ -7,8 +7,10 @@ import type { APIRequestContext } from '@playwright/test'; import { loadState } from '../state.js'; import type { SmokeTestState } from '../state.js'; -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; +let state: SmokeTestState; +let baseUrl = ''; +let sessionId = ''; +let groupPubkeyHex = ''; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { const api = await request.newContext({ baseURL: baseUrl }); @@ -19,6 +21,13 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { + state = loadState(); + baseUrl = state.baseUrl; + sessionId = state.sessionId; + groupPubkeyHex = state.groupPubkeyHex; +}); + test.describe('Status – /api/status', () => { test('GET /api/status is publicly accessible without auth', async () => { // /api/status intentionally allows unauthenticated health checks @@ -91,7 +100,7 @@ test.describe('Peers – /api/peers', () => { expect(res.status()).toBe(200); const body = await res.json(); expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(state.groupPubkeyHex); + expect(body.pubkey).toBe(groupPubkeyHex); expect(typeof body.threshold).toBe('number'); }); }); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts index 5cfc047..36b5ebd 100644 --- a/tests/e2e/specs/04-sign.e2e.ts +++ b/tests/e2e/specs/04-sign.e2e.ts @@ -24,6 +24,7 @@ const { baseUrl, sessionId, groupPubkeyHex } = state; // Valid 32-byte hex event IDs for signing const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; +const SIGNATURE_REGEX = /^[0-9a-f]{128}$/i; async function withApi(fn: (api: APIRequestContext) => Promise): Promise { const api = await request.newContext({ baseURL: baseUrl }); @@ -90,7 +91,7 @@ test.describe('Sign – /api/sign', () => { expect(body).toHaveProperty('signature'); expect(typeof body.signature).toBe('string'); // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); @@ -113,7 +114,7 @@ test.describe('Sign – /api/sign', () => { const body = await res.json(); expect(body).toHaveProperty('id'); expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); @@ -126,7 +127,7 @@ test.describe('Sign – /api/sign', () => { }); expect(res.status()).toBe(200); const body = await res.json(); - expect(body.signature).toMatch(/^[0-9a-f]{128}$/i); + expect(body.signature).toMatch(SIGNATURE_REGEX); }); }); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts index deaa742..6199100 100644 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ b/tests/e2e/specs/06-event-log.e2e.ts @@ -1,6 +1,6 @@ /** * UI event-log smoke tests. - * Signing operations performed in 04-sign.e2e.ts will have produced log entries. + * This suite seeds its own event-log entries in beforeAll. */ import { test, expect, request } from '@playwright/test'; @@ -23,9 +23,10 @@ async function withApi(fn: (api: APIRequestContext) => Promise): Promise { test.beforeAll(async () => { await withApi(async (api) => { + const runUniqueHex = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padStart(64, 'b').slice(0, 64); const seedRes = await api.post('/api/sign', { headers: { 'X-Session-ID': sessionId }, - data: { message: 'b'.repeat(64) }, + data: { message: runUniqueHex }, }); if (!seedRes.ok()) { throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); diff --git a/tests/routes/admin.whoami.session.spec.ts b/tests/routes/admin.whoami.session.spec.ts index a95d8ce..76761c0 100644 --- a/tests/routes/admin.whoami.session.spec.ts +++ b/tests/routes/admin.whoami.session.spec.ts @@ -28,7 +28,9 @@ describe('admin whoami with DB-backed session', () => { const sessionId = auth.createSession(1, '203.0.113.7') expect(sessionId).toBeString() - database.default.exec("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = '" + sessionId + "'") + database.default + .prepare("UPDATE sessions SET last_access = datetime('now', '-1 day') WHERE id = ?") + .run(sessionId) const req = new Request('http://localhost/api/admin/whoami', { headers: { diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index e6a17e8..f5e8667 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -31,7 +31,7 @@ function loadFixtureTestKeysetSecret(): string | undefined { const TEST_KEYSET_SECRET = normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? - loadFixtureTestKeysetSecret(); + normalizeOptionalEnv(loadFixtureTestKeysetSecret()); if (!TEST_KEYSET_SECRET) { throw new Error( 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 6ba153a..1c38aaf 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -42,7 +42,10 @@ function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string const compact = raw.replace(/\s+/g, ' ').trim(); if (!compact) return '(empty)'; const redacted = compact - .replace(/(admin_secret|session_secret|password|api[_-]?key|token)\s*[:=]\s*["']?[^"'\s]+/ig, '$1=') + .replace( + /(["']?(?:admin_secret|session_secret|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, + '$1' + ) .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); return redacted.length > maxChars ? `${redacted.slice(0, maxChars)}...(truncated)` : redacted; } From eb65308ce6c46b6026d1e73854557933715c367f Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 11:56:45 -0600 Subject: [PATCH 43/69] fix: harden credential save and auth validation flows --- frontend/components/Configure.tsx | 37 ++++++++++++++----- .../components/ui/input-with-validation.tsx | 2 +- src/routes/auth.ts | 6 ++- src/routes/onboarding.ts | 20 ++++------ src/routes/user.ts | 11 ++++-- tests/e2e/state.ts | 3 +- 6 files changed, 48 insertions(+), 31 deletions(-) diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index c7f9f32..df2ba17 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -556,19 +556,34 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav setIsGenerating(true); try { + const parseRelayList = (raw: string): string[] | null => { + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return null; + const relays = parsed + .filter((relay): relay is string => typeof relay === 'string') + .map((relay) => relay.trim()) + .filter((relay) => relay.length > 0); + return relays.length > 0 ? relays : null; + } catch { + return null; + } + }; + const resolveRelaysToSave = (): string[] => { - if (Array.isArray(existingRelays) && existingRelays.length > 0) { - return existingRelays; + if (!isHeadlessMode) { + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); + } + return ["wss://relay.primal.net"]; } + if (typeof advancedSettings.RELAYS === 'string' && advancedSettings.RELAYS.trim().length > 0) { - try { - const parsed = JSON.parse(advancedSettings.RELAYS); - if (Array.isArray(parsed) && parsed.every(relay => typeof relay === 'string') && parsed.length > 0) { - return parsed; - } - } catch { - // fall back to default relay list below - } + const parsedRelays = parseRelayList(advancedSettings.RELAYS); + if (parsedRelays) return parsedRelays; + } + if (Array.isArray(existingRelays) && existingRelays.length > 0) { + return existingRelays.map((relay) => relay.trim()).filter((relay) => relay.length > 0); } return ["wss://relay.primal.net"]; }; @@ -594,6 +609,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const detail = await response.text().catch(() => ''); throw new Error(detail || `Failed to save headless credentials (${response.status})`); } + setExistingRelays(relaysToSave); } else { // Database mode - save to user credentials // Preserve existing relays or use default if none exist @@ -616,6 +632,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const detail = await response.text().catch(() => ''); throw new Error(detail || `Failed to save credentials (${response.status})`); } + setExistingRelays(relaysToSave); } setHasExistingCredentials(true); diff --git a/frontend/components/ui/input-with-validation.tsx b/frontend/components/ui/input-with-validation.tsx index 8401b27..08c58f9 100644 --- a/frontend/components/ui/input-with-validation.tsx +++ b/frontend/components/ui/input-with-validation.tsx @@ -2,7 +2,7 @@ import React, { useId } from 'react'; import { Input } from "./input"; import { cn } from "../../lib/utils"; -interface InputWithValidationProps extends Omit, 'onChange'> { +interface InputWithValidationProps extends Omit, 'onChange' | 'required'> { label?: string | React.ReactNode; value: string; onChange: (value: string) => void; diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 8e910a9..9649fcf 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1094,6 +1094,10 @@ export function handleLogout(req: Request): Response { 'Vary': mergedVary, 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Session-ID', + }; + + const logoutHeaders = { + ...headers, 'Set-Cookie': `session=; HttpOnly; Path=/; ${process.env.NODE_ENV === 'production' ? 'Secure; ' : ''}SameSite=Strict; Max-Age=0` }; @@ -1127,7 +1131,7 @@ export function handleLogout(req: Request): Response { try { zeroizeVaultEntryAndDelete(sessionId) } catch {} } - return Response.json({ success: true }, { headers }); + return Response.json({ success: true }, { headers: logoutHeaders }); } // Authentication middleware wrapper (deprecated - use explicit auth parameters instead) diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index a25d975..0c88ade 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -1,4 +1,4 @@ -import { timingSafeEqual } from 'crypto'; +import { createHash, timingSafeEqual } from 'crypto'; import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { ADMIN_SECRET, HEADLESS, SKIP_ADMIN_SECRET_VALIDATION } from '../const.js'; @@ -290,21 +290,15 @@ export async function validateAdminSecret(adminSecret: string | undefined): Prom try { // Coerce to string to prevent type errors const adminSecretStr = String(adminSecret); - const providedSecret = Buffer.from(adminSecretStr); - const expectedSecret = Buffer.from(ADMIN_SECRET); - - // Timing-safe comparison - if (providedSecret.length !== expectedSecret.length) { - return false; - } - - return timingSafeEqual(providedSecret, expectedSecret); + const providedDigest = createHash('sha256').update(adminSecretStr).digest(); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + return timingSafeEqual(providedDigest, expectedDigest); } catch { // On any error, perform dummy comparison to maintain consistent timing - const expectedSecret = Buffer.from(String(ADMIN_SECRET)); - const dummySecret = Buffer.alloc(expectedSecret.length); + const expectedDigest = createHash('sha256').update(String(ADMIN_SECRET)).digest(); + const dummyDigest = Buffer.alloc(expectedDigest.length); try { - timingSafeEqual(dummySecret, expectedSecret); + timingSafeEqual(dummyDigest, expectedDigest); } catch {} return false; } diff --git a/src/routes/user.ts b/src/routes/user.ts index aa27cc9..4d32ba8 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -283,10 +283,13 @@ export async function handleUserRoute( if ('relays' in body) { // Validate relays format - if (body.relays === null || - (Array.isArray(body.relays) && - body.relays.every((r: any) => typeof r === 'string' && isValidWebSocketUrl(r)))) { - updates.relays = body.relays; + if (body.relays === null) { + updates.relays = null; + } else if ( + Array.isArray(body.relays) && + body.relays.every((r: unknown): r is string => typeof r === 'string' && isValidWebSocketUrl(r)) + ) { + updates.relays = body.relays.map((relay: string) => relay.trim()); } else { return Response.json( { error: 'Invalid relay URLs. Must use ws:// or wss://' }, diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts index 68a70d1..8e52078 100644 --- a/tests/e2e/state.ts +++ b/tests/e2e/state.ts @@ -37,8 +37,7 @@ const SMOKE_TEST_STATE_SCHEMA = z.object({ .nonempty('shareCredentials must contain at least one credential'), groupPubkeyHex: z .string() - .min(1, 'groupPubkeyHex must be non-empty') - .regex(/^([0-9a-fA-F]+)$/, 'groupPubkeyHex must be a hex string'), + .regex(/^[0-9a-fA-F]{64}$/, 'groupPubkeyHex must be exactly 64 hex characters'), adminUsername: z.string().min(1, 'adminUsername must be non-empty'), adminPassword: z.string().min(1, 'adminPassword must be non-empty'), adminSecret: z.string().min(1, 'adminSecret must be non-empty'), From a4ebdcb7181125e9a35c1bf3b8fc2741fd7f5f9d Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 16:25:27 -0600 Subject: [PATCH 44/69] chore: remove Playwright e2e suite from repo --- .gitignore | 1 - bun.lock | 11 +- llm/implementation/e2e-smoke-tests.md | 372 -------------- package.json | 7 - playwright.config.ts | 43 -- src/routes/nip04.ts | 6 +- src/routes/nip44.ts | 4 +- tests/e2e/cosigner.mjs | 126 ----- tests/e2e/global-setup.ts | 486 ------------------ tests/e2e/global-teardown.ts | 180 ------- tests/e2e/helpers.ts | 18 - tests/e2e/smoke-test-defaults.json | 3 - tests/e2e/specs/01-auth.e2e.ts | 129 ----- tests/e2e/specs/02-status-peers.e2e.ts | 119 ----- tests/e2e/specs/03-nip44-nip04.e2e.ts | 157 ------ tests/e2e/specs/04-sign.e2e.ts | 152 ------ tests/e2e/specs/05-admin.e2e.ts | 181 ------- tests/e2e/specs/06-event-log.e2e.ts | 110 ---- tests/e2e/specs/07-env.e2e.ts | 110 ---- tests/e2e/specs/08-ui.e2e.ts | 113 ---- tests/e2e/state.ts | 91 ---- tests/routes/env.db-mode.spec.ts | 28 +- ...{status-env.spec.ts => status-env.test.ts} | 0 23 files changed, 8 insertions(+), 2439 deletions(-) delete mode 100644 llm/implementation/e2e-smoke-tests.md delete mode 100644 playwright.config.ts delete mode 100644 tests/e2e/cosigner.mjs delete mode 100644 tests/e2e/global-setup.ts delete mode 100644 tests/e2e/global-teardown.ts delete mode 100644 tests/e2e/helpers.ts delete mode 100644 tests/e2e/smoke-test-defaults.json delete mode 100644 tests/e2e/specs/01-auth.e2e.ts delete mode 100644 tests/e2e/specs/02-status-peers.e2e.ts delete mode 100644 tests/e2e/specs/03-nip44-nip04.e2e.ts delete mode 100644 tests/e2e/specs/04-sign.e2e.ts delete mode 100644 tests/e2e/specs/05-admin.e2e.ts delete mode 100644 tests/e2e/specs/06-event-log.e2e.ts delete mode 100644 tests/e2e/specs/07-env.e2e.ts delete mode 100644 tests/e2e/specs/08-ui.e2e.ts delete mode 100644 tests/e2e/state.ts rename tests/routes/{status-env.spec.ts => status-env.test.ts} (100%) diff --git a/.gitignore b/.gitignore index 9945c7b..eda8834 100644 --- a/.gitignore +++ b/.gitignore @@ -65,7 +65,6 @@ test-*.sh debug-*.js verify-*.md test-results/ -playwright-report/ # LLM files .claude diff --git a/bun.lock b/bun.lock index ce4e1de..f7af0d5 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,6 @@ "zod": "^3.25.76", }, "devDependencies": { - "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", @@ -196,8 +195,6 @@ "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="], - "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -438,7 +435,7 @@ "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -620,10 +617,6 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], - "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "polished": ["polished@4.3.1", "", { "dependencies": { "@babel/runtime": "^7.17.8" } }, "sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA=="], @@ -842,8 +835,6 @@ "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "concurrently/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], diff --git a/llm/implementation/e2e-smoke-tests.md b/llm/implementation/e2e-smoke-tests.md deleted file mode 100644 index 19fcdc4..0000000 --- a/llm/implementation/e2e-smoke-tests.md +++ /dev/null @@ -1,372 +0,0 @@ -# E2E Smoke Test Suite (Playwright – DB Mode) - -Last verified: 2026-02-20 -Status snapshot: all Playwright smoke tests passing as of the verification date above. For current counts, run `npx playwright test --list`. - -## Purpose - -The Playwright smoke test suite exercises igloo-server end-to-end in **database mode** (the default, `HEADLESS=false`). It starts a real server process, spins up a live FROSTR co-signer, completes the full onboarding flow, and then runs two categories of tests: - -- **API project** (`01`–`07`): Pure HTTP request-context tests — no browser. Cover auth, status, peers, NIP-44, NIP-04, signing, admin, event log, and credential management. -- **UI project** (`08`): Headless Chrome browser tests. Cover the login page, tab navigation, and the Event Log section embedded in the Signer tab. - -## Running the Tests - -```bash -# Full suite (both projects) -npx playwright test - -# API-only (faster, no browser dependency) -npx playwright test --project=api - -# UI-only -npx playwright test --project=ui - -# Single spec file -npx playwright test tests/e2e/specs/04-sign.e2e.ts - -# HTML report (opens automatically after a run that had failures) -npx playwright show-report -``` - -Prerequisites: -- `bun run build` must have been run at least once so `static/app.js` exists (the UI tests load the SPA). -- `@playwright/test` and Chromium browser installed (`npx playwright install chromium`). -- Keep port 18002 free when possible. `tests/e2e/global-setup.ts` calls `resolvePort()` and will usually fall back to a random free port if 18002 is busy, but hard-coded references can still break if the preferred port is unavailable. -- Admin credentials must be provided before running tests. Set `SMOKE_ADMIN_SECRET`, `SMOKE_ADMIN_USERNAME`, and `SMOKE_ADMIN_PASSWORD`, or provide `tests/e2e/smoke-test.local.json`; otherwise `tests/e2e/global-setup.ts` exits early. - -## File Structure - -```text -tests/e2e/ -├── global-setup.ts # Starts server + co-signer, completes onboarding, writes state.json -├── global-teardown.ts # SIGTERMs both processes, deletes temp dir -├── state.ts # loadState() helper — reads JSON written by global-setup -├── cosigner.mjs # Minimal FROSTR co-signer subprocess (node/ESM) -└── specs/ - ├── 01-auth.e2e.ts - ├── 02-status-peers.e2e.ts - ├── 03-nip44-nip04.e2e.ts - ├── 04-sign.e2e.ts - ├── 05-admin.e2e.ts - ├── 06-event-log.e2e.ts - ├── 07-env.e2e.ts - └── 08-ui.e2e.ts - -playwright.config.ts # Project definitions: "api" (01–07), "ui" (08) -``` - -## Global Setup (`global-setup.ts`) - -The setup runs **once** before all tests and does the following in order: - -### 1. Generate a 2-of-2 FROSTR keyset - -```typescript -const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); -``` - -A **2-of-2** (not 2-of-3) scheme is used deliberately: with exactly two shares, the one connected co-signer is always sufficient to reach threshold without any ambiguity about which peer is needed. `TEST_NSEC_HEX` is a fixed 32-byte private key so the keyset is deterministic across runs. - -### 2. Start igloo-server - -The server is spawned via `spawnDetached('bun', ['run', 'src/server.ts'], env, logFile)`. Key env overrides: - -| Variable | Value | Reason | -|---|---|---| -| `HOST_PORT` | `18002` | Fixed test port | -| `HOST_NAME` | `127.0.0.1` | Loopback only | -| `ADMIN_SECRET` | from env/fixture (e.g. `$SMOKE_ADMIN_SECRET`) | Loaded from environment or local fixture — do not commit secrets | -| `DB_PATH` | `$TMPDIR/igloo-smoke-test/db` | Fresh DB per run | -| `RATE_LIMIT_ENABLED` | `false` | Avoid rate-limit failures in rapid-fire tests | -| `SKIP_RELAY_PROBE` | `true` | Skip external relay verification at startup | -| `ALLOW_LOCALHOST_RELAY` | `true` | Allow `ws://127.0.0.1:18002` as a relay URL | -| `FROSTR_SIGN_TIMEOUT` | `5000` | Cap setup/sign probe latency to 5 s per request | -| `GROUP_CRED` | `''` | Clear any `.env` credential interference | -| `SHARE_CRED` | `''` | Clear any `.env` credential interference | -| `RELAYS` | `''` | Clear any `.env` relay interference | -| `NODE_ENV` | `test` | Suppresses some production-only behaviors | - -**Critical**: Bun automatically loads `.env` from the current directory into `process.env`. If the developer's `.env` contains stale `GROUP_CRED`/`SHARE_CRED`/`RELAYS` values from a different port, the server would connect its bifrost node to the wrong relay, making signing always time out. The empty-string overrides above force those variables to be blank regardless of what `.env` contains. - -### 3. Complete onboarding - -```text -POST /api/onboarding/validate-admin (Bearer ADMIN_SECRET) -POST /api/onboarding/setup (creates admin user with username + password) -POST /api/auth/login → sessionId -``` - -### 4. Set FROSTR credentials - -```text -POST /api/user/credentials { group_cred, share_cred: shareCredentials[0], relays: ['ws://127.0.0.1:18002'] } -``` - -In DB mode, credentials are stored per-user encrypted in SQLite. The server then creates the in-memory bifrost node and polls `GET /api/status` until `nodeActive === true`. - -### 5. Start co-signer - -```bash -node tests/e2e/cosigner.mjs ws://127.0.0.1:18002 -``` - -`cosigner.mjs` creates a bifrost node using `@frostr/igloo-core` directly (no igloo-cli TUI). The server holds `shareCredentials[0]`; the co-signer holds `shareCredentials[1]`. Both connect to the server's built-in Nostr relay at `ws://127.0.0.1:18002`. - -### 6. Signing readiness probe - -Up to 5 attempts (3 s apart) to POST a 32-byte hex message to `/api/sign`. Success confirms the threshold is reachable and the relay subscription is active on both sides. Setup aborts if signing never succeeds. - -### 7. Create a persistent API key - -`POST /api/admin/api-keys { label: 'smoke-test-key' }` — the returned token is saved in `state.json` as `apiKey` and used in tests that verify API key authentication. - -### 8. Write shared state - -Everything is serialized to `$TMPDIR/igloo-smoke-test/state.json` and the path is exported as `SMOKE_STATE_FILE`. Every spec file calls `loadState()` at module level to read this file. - -## Shared State (`state.ts`) - -`loadState()` reads `SMOKE_STATE_FILE`. During Playwright's test discovery phase (when `--list` is run or the config is imported without a server running) `SMOKE_STATE_FILE` is not set, so the function returns a harmless stub with empty strings. Tests only execute after global-setup has populated the real state. - -```typescript -interface SmokeTestState { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; // live admin session from global-setup login - apiKey: string | null; // DB-backed API key token - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; // [0] = server share, [1] = cosigner share - groupPubkeyHex: string; // x-only (no 02/03 prefix) - adminUsername: string; - adminPassword: string; - adminSecret: string; -} -``` - -## Spec Coverage - -### `01-auth.e2e.ts` — Authentication - -- `GET /api/auth/status` returns available auth methods -- `POST /api/auth/login` — valid credentials return `sessionId` -- `POST /api/auth/login` — wrong/unknown password returns 401 -- `GET /api/peers` — no auth returns 401 *(uses `/api/peers`, not `/api/status` — see design decisions below)* -- `GET /api/status` — valid session and API key (X-API-Key and Bearer formats) return 200 -- `GET /api/peers` — invalid API key returns 401 -- `POST /api/auth/logout` — invalidates session; subsequent `GET /api/peers` returns 401 - -### `02-status-peers.e2e.ts` — Status and Peers - -- `GET /api/status` — publicly accessible without auth (intentional design; returns 200) -- `GET /api/status` — with session returns full node info: `serverRunning`, `nodeActive`, `health`, `relayCount`, `timestamp` -- `GET /api/status` — health object has `isConnected`, `consecutiveConnectivityFailures` -- `GET /api/peers` — 401 without auth -- `GET /api/peers` — returns peer list with `peers`, `total`, `online` -- `GET /api/peers/group` — returns `pubkey` (matches `state.groupPubkeyHex`), `threshold` -- `GET /api/peers/self` — returns own share pubkey - -### `03-nip44-nip04.e2e.ts` — NIP-44 and NIP-04 Encryption - -NIP-44: -- 401 without auth -- Encrypt returns ciphertext -- Encrypt → decrypt round-trips plaintext -- Invalid `peer_pubkey` returns 400 -- Missing `content` returns 400 - -NIP-04: -- 401 without auth -- Encrypt returns ciphertext with IV suffix (NIP-04 format: `?iv=`) -- Encrypt → decrypt round-trips plaintext -- Invalid `peer_pubkey` returns 400 - -Uses `state.groupPubkeyHex` as the peer pubkey for encryption (the server encrypts to itself for round-trip tests). - -### `04-sign.e2e.ts` — Threshold Signing - -- 401 without auth -- 400 for non-hex message -- 400 for message shorter than 32 bytes -- 400 for missing body -- Signs a 32-byte hex message; response contains `id` and `signature` -- Signs a full Nostr event object (with `id`, `pubkey`, `content`, `kind`, `created_at`, `tags`) -- Signs with API key auth (`X-API-Key` header) — confirms DB-backed API keys work for signing -- 400 for event with invalid pubkey - -Signing tests exercise the complete FROSTR threshold flow: server publishes a sign request over the relay, co-signer responds with a partial signature, server aggregates and returns the final signature. - -### `05-admin.e2e.ts` — Admin Endpoints - -API key management: -- `GET /api/admin/api-keys` returns list (includes key from global-setup) -- `POST /api/admin/api-keys` creates a key (returns 201 with `token`, `id`) -- New API key authenticates successfully -- Revoked API key returns 401: creates key → verify works on `/api/event-log` → revoke → verify 401 on `/api/event-log` - -User management: -- `GET /api/admin/users` returns users list; admin user is present -- `GET /api/admin/whoami` returns `userId` -- Both require auth (401 without) - -### `06-event-log.e2e.ts` — UI Event Log - -- `GET /api/event-log` — 401 without auth -- Returns `{ entries: [...] }` with valid shape (`type`, `message`, `timestamp`) -- Pagination: `?limit=5` returns ≤ 5 entries -- `GET /api/event-log/export` — streams NDJSON (`Content-Type: application/x-ndjson`); each line parses as valid JSON -- Export — 401 without auth - -### `07-env.e2e.ts` — Credential / Env Management - -- `GET /api/env` — 401 without auth -- `GET /api/env` with session — returns `{ hasCredentials: true, ... }` -- `POST /api/env` — invalid `GROUP_CRED` returns 400 -- `POST /api/env` — invalid `SHARE_CRED` returns 400 -- `POST /api/env` — invalid relay URL returns 400 -- `POST /api/env` — without auth returns 401 - -### `08-ui.e2e.ts` — Browser UI (Headless Chrome) - -Login page: -- `/` renders login form (username + password inputs visible) -- Login form fills credentials and reaches the dashboard (tabs visible) - -Authenticated app (each test logs in fresh via `beforeEach`): -- Signer tab is visible after login -- Configure tab is accessible (click navigates, inputs render) -- API Keys tab renders without "Something went wrong" -- Event Log collapsible section (inside Signer tab, not a separate tab) is visible, click-to-expand works, no errors -- Logout button signs out and returns to login form - -Onboarding: -- `/` does not show "Admin Secret" text when DB is already initialized - -## Design Decisions and Gotchas - -### `/api/status` is intentionally public - -`/api/status` bypasses the main authentication check in `src/routes/index.ts`: - -```typescript -const isStatusEndpoint = url.pathname === '/api/status'; -// Auth check skips status: -if (url.pathname.startsWith('/api/') && AUTH_CONFIG.ENABLED && !isPublicEndpoint && !isStatusEndpoint && ...) { -``` - -This is by design — unauthenticated health checks and monitoring probes must be able to reach the status endpoint. Consequently: -- Tests that verify 401 enforcement **must use a different endpoint** (e.g., `GET /api/peers` or `GET /api/event-log`). -- Tests that verify authenticated 200 responses can still use `/api/status` (they pass with or without auth). - -### DB API keys and per-user credential lookup - -Database-backed API keys authenticate via `authenticateDatabaseApiKey()` and return `userId: 'api-key:'` — a string, not a numeric DB row ID. Several routes in DB mode call `getCredentials(auth)` which requires a numeric `userId` to decrypt per-user credentials from SQLite. If `userId` is not numeric, `getCredentials` returns `null` and the route responds 401. - -Affected routes: `GET /api/peers`, `GET /api/peers/group`, `GET /api/peers/self`. -Unaffected: `POST /api/sign`, `GET /api/event-log`, NIP-44/NIP-04 (which use the in-memory node directly or don't need per-user credential lookup). - -For this reason: -- The "revoked API key returns 401" test in `05-admin.e2e.ts` uses `GET /api/event-log` (not `/api/peers`) for the pre/post-revocation auth check. -- The "new API key can authenticate" test uses `GET /api/event-log` to exercise real API-key auth enforcement on a protected endpoint. - -### Event log export is NDJSON, not JSON - -`GET /api/event-log/export` returns `Content-Type: application/x-ndjson` with one JSON object per line (newline-delimited JSON). Calling `response.json()` on this response fails because the body as a whole is not valid JSON. The test reads the body as text and parses each line individually: - -```typescript -const text = await res.text(); -const lines = text.trim().split('\n').filter(Boolean); -for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); -} -``` - -### `POST /api/env` validates credential format in DB mode - -The DB-mode `POST /api/env` handler (in `src/routes/env.ts`) validates `GROUP_CRED` and `SHARE_CRED` using `validateGroup()` / `validateShare()` from `@frostr/igloo-core` before writing to the `.env` file. Invalid credentials return 400. This validation was added during test development; it was previously only present on the headless `/api/env/shares` path. - -### Event Log is embedded in Signer tab, not a top-level tab - -The application has four top-level tabs: **Signer**, **NIP-46**, **API Keys**, **Recover**. There is no "Event Log" tab. The event log is a collapsible section within the Signer tab rendered as a `div[role="button"]` containing a `Event Log`. The UI test locates it with: - -```typescript -page.locator('[role="button"]:has-text("Event Log")').first() -``` - -### `.env` file interference with test server - -Bun loads `.env` from the current working directory automatically. A developer's `.env` may contain `GROUP_CRED`, `SHARE_CRED`, or `RELAYS` pointing to a production relay or a different port. If these leak into the test server's environment, the server creates a bifrost node at startup using the old credentials (different relay URL), and when the test then POSTs new credentials the server logs "Node already running, skipping restart" and stays connected to the wrong relay. The co-signer connects to the test relay, the server connects elsewhere — signing always times out. - -**Fix**: global-setup passes explicit empty-string overrides for all three variables when spawning the server: -```typescript -GROUP_CRED: '', -SHARE_CRED: '', -RELAYS: '', -``` - -### nostr-tools 2.x REQ filter format - -`@frostr/igloo-core` (which depends on `nostr-tools` 2.x) sends REQ messages in the format: -```json -["REQ", "sub_id", [{"kinds":[20004],"#p":[""]}]] -``` -Note the **array-wrapped filter** as the third element. NIP-01 expects filters as positional arguments: -```json -["REQ", "sub_id", {"kinds":[20004],"#p":[""]}] -``` - -The built-in relay (`src/class/relay.ts`) normalizes this in `_handler`: -```typescript -if (payload.length === 2 && Array.isArray(payload[1])) { - payload = [payload[0], ...payload[1]]; -} -``` - -Without this fix, the server's relay would reject all subscriptions from the bifrost node (logging "bad req: provided filter is not an object") and signing would always time out. - -## Temp Directory Layout - -Each run creates a fresh temp directory at `$TMPDIR/igloo-smoke-test/` (deleted by teardown): - -```text -igloo-smoke-test/ -├── db/ # SQLite database files (igloo.db, .session-secret) -├── state.json # Shared test state (pids, session, credentials, etc.) -├── server.log # igloo-server stdout/stderr -└── cosigner.log # co-signer subprocess stdout/stderr -``` - -If a run fails unexpectedly (e.g., setup throws before teardown registers), the temp dir may be left behind. It is safe to delete manually. - -## Adding New Tests - -1. Create `tests/e2e/specs/NN-name.e2e.ts`. -2. Import `loadState` from `../state.js` and call it at module level. -3. Use `state.sessionId` for session-authenticated requests, `state.apiKey` for API key requests. -4. Add the spec to the correct project in `playwright.config.ts` (update `testMatch` if needed, or rely on the `0[1-7]-*.e2e.ts` glob for API specs). -5. If testing a credential-sensitive endpoint in DB mode (peers, env), use `state.sessionId` — DB API keys cannot look up per-user credentials. - -## Files - -| File | Role | -|---|---| -| `tests/e2e/global-setup.ts` | Server lifecycle, onboarding, state serialization | -| `tests/e2e/global-teardown.ts` | SIGTERM + temp dir cleanup | -| `tests/e2e/state.ts` | `SmokeTestState` type and `loadState()` | -| `tests/e2e/cosigner.mjs` | Minimal co-signer subprocess (ESM, no TUI) | -| `tests/e2e/specs/01-auth.e2e.ts` | Auth enforcement, login/logout | -| `tests/e2e/specs/02-status-peers.e2e.ts` | Node status, peer list | -| `tests/e2e/specs/03-nip44-nip04.e2e.ts` | NIP-44 / NIP-04 encrypt+decrypt | -| `tests/e2e/specs/04-sign.e2e.ts` | Threshold Schnorr signing | -| `tests/e2e/specs/05-admin.e2e.ts` | API key CRUD, revocation, user management | -| `tests/e2e/specs/06-event-log.e2e.ts` | Event log pagination and NDJSON export | -| `tests/e2e/specs/07-env.e2e.ts` | Credential/env endpoint validation | -| `tests/e2e/specs/08-ui.e2e.ts` | Headless Chrome SPA smoke tests | -| `playwright.config.ts` | Project config, timeout, reporter, globalSetup/Teardown | -| `src/routes/env.ts` | DB-mode POST validates GROUP_CRED / SHARE_CRED format | -| `src/class/relay.ts` | Normalizes nostr-tools 2.x double-wrapped REQ filters | -| `src/routes/utils.ts` | `ALLOW_LOCALHOST_RELAY` bypass for test relay URLs | diff --git a/package.json b/package.json index 51368fb..9fa9da0 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,6 @@ "api:test:ws": "bun scripts/api/test-ws-events.ts", "api:test:nip": "bun scripts/api/test-nip44-nip04.ts", "test:unit": "bun test --max-concurrency=1 src tests/routes", - "test:e2e:smoke": "npx playwright test --project=api tests/e2e/specs/01-auth.e2e.ts tests/e2e/specs/04-sign.e2e.ts tests/e2e/specs/05-admin.e2e.ts", - "test:e2e": "npx playwright test", - "test:e2e:nightly": "npx playwright test --project=api --project=ui --retries=2 --timeout=60000", - "test:e2e:ui": "npx playwright test --project=ui", - "test:e2e:api": "npx playwright test --project=api", - "test:e2e:report": "npx playwright show-report", "typecheck": "tsc --noEmit", "tsc": "tsc --noEmit" }, @@ -66,7 +60,6 @@ "zod": "^3.25.76" }, "devDependencies": { - "@playwright/test": "^1.58.2", "@redocly/cli": "^1.34.5", "@types/node": "^22.18.12", "@types/react": "^18.3.26", diff --git a/playwright.config.ts b/playwright.config.ts deleted file mode 100644 index 97b856f..0000000 --- a/playwright.config.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -export default defineConfig({ - testDir: './tests/e2e/specs', - globalSetup: './tests/e2e/global-setup.ts', - globalTeardown: './tests/e2e/global-teardown.ts', - - // Run all tests sequentially – they share one live server + co-signer process - fullyParallel: false, - workers: 1, - - // Retry once on CI to absorb timing flakes - retries: process.env.CI ? 1 : 0, - - reporter: [ - ['list'], - ['html', { outputFolder: 'playwright-report', open: 'never' }], - ], - - use: { - trace: 'on-first-retry', - // Longer default for operations that wait on bifrost relay round-trips - actionTimeout: 15_000, - }, - - projects: [ - // Pure API specs – use request context only, no browser - { - name: 'api', - testMatch: ['**/[0-9][0-9]-*.e2e.ts'], - testIgnore: ['**/08-ui.e2e.ts'], - }, - // Browser UI spec (08) – needs a real browser - { - name: 'ui', - testMatch: ['**/08-ui.e2e.ts'], - use: { ...devices['Desktop Chrome'], headless: true }, - }, - ], - - // Global per-test timeout – sign tests can take up to 15 s - timeout: 30_000, -}); diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index 135543c..c9ab656 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -85,12 +85,14 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }) - // Separate bucket for e2e crypto ops + // Separate bucket for crypto operations const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) + const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900 return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, - headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() } + headers: { ...headers, 'Retry-After': retryAfter.toString() } }) } diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index b0d71ef..9c0e07c 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -34,8 +34,8 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon } if (!context.node) return Response.json({ error: 'Node not available' }, { status: 503, headers }); - // Basic rate limit for e2e crypto ops - // Separate bucket for e2e crypto ops + // Basic rate limit for crypto operations + // Use a dedicated bucket separate from signing traffic. const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { diff --git a/tests/e2e/cosigner.mjs b/tests/e2e/cosigner.mjs deleted file mode 100644 index 9e655d7..0000000 --- a/tests/e2e/cosigner.mjs +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Minimal FROSTR co-signer for smoke tests. - * - * Usage: node cosigner.mjs - */ - -const [,, groupCred, shareCred, relayUrl] = process.argv; - -if (!groupCred || !shareCred || !relayUrl) { - console.error('Usage: cosigner.mjs '); - process.exit(1); -} - -const { - createBifrostNode, - connectNode, -} = await import('@frostr/igloo-core'); - -const CONNECT_TIMEOUT_MS_RAW = process.env.SMOKE_COSIGNER_CONNECT_TIMEOUT_MS ?? '20000'; -const parsedConnectTimeout = Number.parseInt(CONNECT_TIMEOUT_MS_RAW, 10); -const CONNECT_TIMEOUT_MS = Number.isFinite(parsedConnectTimeout) && parsedConnectTimeout > 0 - ? parsedConnectTimeout - : 20000; - -async function connectWithTimeout(nodeInstance, relay) { - let timeoutId; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject(new Error(`connectNode timeout after ${CONNECT_TIMEOUT_MS}ms for relay ${relay}`)); - }, CONNECT_TIMEOUT_MS); - }); - - const connectionPromise = connectNode(nodeInstance); - connectionPromise.catch(() => {}); - - try { - await Promise.race([connectionPromise, timeoutPromise]); - } finally { - if (timeoutId) clearTimeout(timeoutId); - } -} - -/** - * Serializes a value to JSON, replacing circular refs with "[Circular]" to avoid - * "Converting circular structure to JSON" TypeError from bubbling into outer catch. - * @param {unknown} obj - Value to serialize - * @returns {string} JSON string or fallback representation - */ -function safeStringify(obj) { - const seen = new WeakSet(); - function replacer(_key, value) { - if (typeof value === 'object' && value !== null) { - if (seen.has(value)) return '[Circular]'; - seen.add(value); - } - return value; - } - try { - return JSON.stringify(obj, replacer); - } catch (e) { - return '[Non-serializable]'; - } -} - -let node; -let shuttingDown = false; -try { - node = createBifrostNode({ - group: groupCred, - share: shareCred, - relays: [relayUrl], - }, { enableLogging: false }); - - node.on('ready', () => { - console.log('[cosigner] Node ready. PubKey:', node.pubkey?.slice(0, 16)); - console.log('[cosigner] Peers:', node.peers.map(p => p.pubkey?.slice(0, 16)).join(', ')); - }); - node.on('closed', () => { - if (shuttingDown) { - console.log('[cosigner] Node closed'); - return; - } - console.error('[cosigner] Node closed unexpectedly'); - process.exit(1); - }); - node.on('error', (e) => console.log('[cosigner] Error:', String(e).slice(0, 200))); - node.on('bounced', (...args) => console.log('[cosigner] Bounced:', safeStringify(args).slice(0, 200))); - node.on('message', (msg) => { - console.log('[cosigner] Message tag:', msg?.tag, '| from:', msg?.env?.pubkey?.slice(0,16)); - }); - node.on('/sign/handler/req', (msg) => console.log('[cosigner] SIGN REQ received, id:', msg?.id)); - node.on('/sign/handler/res', () => console.log('[cosigner] SIGN RES sent')); - node.on('/sign/handler/rej', (...a) => console.log('[cosigner] SIGN REJ:', safeStringify(a).slice(0, 200))); - - // Also spy on the raw WebSocket to confirm relay subscription - node.on('subscribed', (...a) => console.log('[cosigner] Subscribed to relay, sub_id:', safeStringify(a).slice(0, 100))); - - console.log('[cosigner] Connecting to relay:', relayUrl); - await connectWithTimeout(node, relayUrl); - console.log('[cosigner] Connected. Pubkey:', node.pubkey); - const filter = node.client?.filter; - if (filter !== undefined) { - console.log('[cosigner] Filter (public):', safeStringify(filter)); - } else { - // TODO: Track upstream accessor support in @frostr/igloo-core if filter visibility is needed. - console.warn('[cosigner] Filter unavailable on node.client.filter (public accessor missing)'); - } - -} catch (err) { - const message = err instanceof Error ? err.message : String(err); - const stack = err instanceof Error && err.stack ? `\n${err.stack}` : ''; - console.error(`[cosigner] Failed to start: ${message}${stack}`); - process.exit(2); -} - -const shutdown = () => { - shuttingDown = true; - try { node?.close?.(); } catch (e) { - console.error('[cosigner] Error closing node:', e instanceof Error ? e.message : String(e)); - } - process.exit(0); -}; -process.on('SIGTERM', shutdown); -process.on('SIGINT', shutdown); - -setInterval(() => {}, 60_000); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts deleted file mode 100644 index c9b94b4..0000000 --- a/tests/e2e/global-setup.ts +++ /dev/null @@ -1,486 +0,0 @@ -/** - * Playwright global setup for DB-mode smoke tests. - * - * What this does: - * 1. Generate a deterministic 2-of-2 FROSTR keyset. - * 2. Start igloo-server against a fresh temporary DB path. - * 3. Complete onboarding and login. - * 4. Persist user credentials to start the Bifrost node. - * 5. Start a real co-signer process. - * 6. Probe signing readiness. - * 7. Create a reusable API key for auth tests. - * 8. Persist shared state for specs and teardown. - */ - -import { request } from '@playwright/test'; -import type { APIRequestContext, FullConfig } from '@playwright/test'; -import { spawn } from 'child_process'; -import type { ChildProcess } from 'child_process'; -import fs from 'fs'; -import net from 'net'; -import os from 'os'; -import path from 'path'; -import type { SmokeTestState } from './state.js'; - -const REQUESTED_PORT_RAW = process.env.SMOKE_TEST_PORT ?? '18002'; -const REQUESTED_PORT = Number.parseInt(REQUESTED_PORT_RAW, 10); -const DEFAULT_PORT = Number.isFinite(REQUESTED_PORT) && REQUESTED_PORT > 0 ? REQUESTED_PORT : 18002; -const RUN_ID = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const TMP_DIR = process.env.SMOKE_TEST_TMP_DIR ?? path.join(os.tmpdir(), `igloo-smoke-test-${RUN_ID}`); -const STATE_FILE = path.join(TMP_DIR, 'state.json'); -const DB_PATH = path.join(TMP_DIR, 'db'); -const SERVER_LOG = path.join(TMP_DIR, 'server.log'); -const COSIGNER_LOG = path.join(TMP_DIR, 'cosigner.log'); - -const smokeDefaultsPath = path.resolve('tests/e2e/smoke-test-defaults.json'); -const smokeDefaultsRaw: unknown = JSON.parse(fs.readFileSync(smokeDefaultsPath, 'utf8')); -if (typeof smokeDefaultsRaw !== 'object' || smokeDefaultsRaw === null) { - throw new Error(`smoke-test-defaults.json must be a JSON object, got ${typeof smokeDefaultsRaw}`); -} -const raw = smokeDefaultsRaw as Record; -if (typeof raw.testNsecHex !== 'string' || raw.testNsecHex.trim().length === 0) { - throw new Error( - 'smoke-test-defaults.json is missing required non-empty string property: testNsecHex.', - ); -} -const smokeDefaults = raw as { - testNsecHex: string; -}; - -function loadOptionalLocalSmokeCredentials(): Partial<{ - adminSecret: string; - adminUsername: string; - adminPassword: string; -}> { - const localFixturePath = - process.env.SMOKE_LOCAL_FIXTURE_PATH?.trim() || - path.resolve('tests/e2e/smoke-test.local.json'); - if (!fs.existsSync(localFixturePath)) { - return {}; - } - try { - const localRaw: unknown = JSON.parse(fs.readFileSync(localFixturePath, 'utf8')); - if (typeof localRaw !== 'object' || localRaw === null) { - throw new Error('expected JSON object'); - } - const fixture = localRaw as Record; - return { - adminSecret: typeof fixture.adminSecret === 'string' && fixture.adminSecret.trim().length > 0 - ? fixture.adminSecret - : undefined, - adminUsername: typeof fixture.adminUsername === 'string' && fixture.adminUsername.trim().length > 0 - ? fixture.adminUsername - : undefined, - adminPassword: typeof fixture.adminPassword === 'string' && fixture.adminPassword.trim().length > 0 - ? fixture.adminPassword - : undefined, - }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to parse local smoke fixture at ${localFixturePath}: ${detail}`); - } -} - -const localSmokeCredentials = loadOptionalLocalSmokeCredentials(); - -function requireNonEmptyString(value: string | undefined, errorMessage: string): string { - if (!value || value.trim().length === 0) { - throw new Error(errorMessage); - } - return value; -} - -function validateTestNsecHex(raw: string): string { - const normalized = raw.trim(); - if (!/^[0-9a-fA-F]+$/.test(normalized) || normalized.length % 2 !== 0 || normalized.length !== 64) { - throw new Error( - 'Invalid TEST_NSEC_HEX: expected a 32-byte private key encoded as exactly 64 hex characters.' - ); - } - return normalized.toLowerCase(); -} - -// Defaults come from fixture for local CI; callers can still override via environment. -const TEST_NSEC_HEX = validateTestNsecHex(process.env.TEST_NSEC_HEX ?? smokeDefaults.testNsecHex); -const MISSING_SMOKE_CREDS_MESSAGE = - 'Smoke admin credentials are required. Set SMOKE_ADMIN_SECRET, SMOKE_ADMIN_USERNAME, and ' + - 'SMOKE_ADMIN_PASSWORD (or provide tests/e2e/smoke-test.local.json).'; -const ADMIN_SECRET = requireNonEmptyString( - process.env.SMOKE_ADMIN_SECRET ?? process.env.ADMIN_SECRET ?? localSmokeCredentials.adminSecret, - MISSING_SMOKE_CREDS_MESSAGE -); -const ADMIN_USERNAME = requireNonEmptyString( - process.env.SMOKE_ADMIN_USERNAME ?? process.env.ADMIN_USERNAME ?? localSmokeCredentials.adminUsername, - MISSING_SMOKE_CREDS_MESSAGE -); -const ADMIN_PASSWORD = requireNonEmptyString( - process.env.SMOKE_ADMIN_PASSWORD ?? process.env.ADMIN_PASSWORD ?? localSmokeCredentials.adminPassword, - MISSING_SMOKE_CREDS_MESSAGE -); - -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function ensurePrivateDir(dirPath: string): void { - fs.mkdirSync(dirPath, { recursive: true, mode: 0o700 }); - try { - fs.chmodSync(dirPath, 0o700); - } catch {} -} - -function writeState(state: SmokeTestState): void { - ensurePrivateDir(path.dirname(STATE_FILE)); - fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); - try { - fs.chmodSync(STATE_FILE, 0o600); - } catch {} - process.env.SMOKE_STATE_FILE = STATE_FILE; -} - -function terminateProcess(proc: ChildProcess | null, label: string): void { - if (!proc?.pid) return; - try { - process.kill(proc.pid, 'SIGTERM'); - console.log(`[setup] Sent SIGTERM to ${label} (pid ${proc.pid})`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { - console.warn(`[setup] Could not stop ${label} (pid ${proc.pid})`, err); - } - } -} - -// Port probing is inherently TOCTOU: we can only test availability now, not reserve it -// forever. For CI smoke tests this low-probability race is acceptable. -function canBindPort(port: number, host: string): Promise { - return new Promise(resolve => { - const srv = net.createServer(); - srv.once('error', () => resolve(false)); - srv.listen(port, host, () => { - srv.close(() => resolve(true)); - }); - }); -} - -// Reserve an ephemeral port by binding to :0 and immediately closing; another process -// could still claim it before spawn, but this is sufficient for smoke test setup. -function reserveRandomPort(host: string): Promise { - return new Promise((resolve, reject) => { - const srv = net.createServer(); - srv.once('error', reject); - srv.listen(0, host, () => { - const addr = srv.address(); - const port = typeof addr === 'object' && addr ? addr.port : 0; - srv.close(() => resolve(port)); - }); - }); -} - -// Prefer the requested port, but fall back when busy. This does not eliminate the -// bind race between probing and process startup. -async function resolvePort(host: string, preferredPort: number): Promise { - if (await canBindPort(preferredPort, host)) { - return preferredPort; - } - const fallbackPort = await reserveRandomPort(host); - if (!Number.isInteger(fallbackPort) || fallbackPort < 1 || fallbackPort > 65535) { - throw new Error( - `[setup] Failed to reserve a valid fallback port after preferred port ${preferredPort} was busy (got: ${fallbackPort})`, - ); - } - console.warn(`[setup] Port ${preferredPort} in use, falling back to ${fallbackPort} (probe-close race still applies)`); - return fallbackPort; -} - -async function pollUntil( - fn: () => Promise, - timeoutMs: number, - intervalMs = 1000, - label = 'condition', -): Promise { - const deadline = Date.now() + timeoutMs; - let consecutiveFailures = 0; - while (Date.now() < deadline) { - try { - if (await fn()) return; - consecutiveFailures = 0; - } catch (error) { - consecutiveFailures += 1; - const nearingDeadline = Date.now() + intervalMs >= deadline; - if (consecutiveFailures === 3 || nearingDeadline) { - const detail = error instanceof Error ? (error.stack ?? error.message) : String(error); - console.warn(`[setup] pollUntil(${label}) transient failure x${consecutiveFailures}: ${detail}`); - } - } - await sleep(intervalMs); - } - throw new Error(`Timed out waiting for: ${label}`); -} - -async function waitForHttp(url: string, timeoutMs: number): Promise { - await pollUntil( - async () => { - const res = await fetch(url).catch(() => null); - return res !== null && res.status < 500; - }, - timeoutMs, - 500, - `HTTP ${url}`, - ); -} - -function spawnDetached( - cmd: string, - args: string[], - env: NodeJS.ProcessEnv, - logFile: string, -): ChildProcess { - const out = fs.openSync(logFile, 'a'); - try { - const proc = spawn(cmd, args, { - env: { ...process.env, ...env }, - detached: false, - stdio: ['ignore', out, out], - }); - proc.on('error', err => { - fs.appendFileSync(logFile, `\n[spawn error] ${err.message}\n`); - }); - return proc; - } finally { - fs.closeSync(out); - } -} - -export default async function globalSetup(_config: FullConfig): Promise { - const host = '127.0.0.1'; - const port = await resolvePort(host, DEFAULT_PORT); - const baseUrl = `http://${host}:${port}`; - - const state: SmokeTestState = { - port, - baseUrl, - tmpDir: TMP_DIR, - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: ADMIN_USERNAME, - adminPassword: ADMIN_PASSWORD, - adminSecret: ADMIN_SECRET, - }; - - let api: APIRequestContext | null = null; - let serverProcess: ChildProcess | null = null; - let cosignerProcess: ChildProcess | null = null; - - process.env.SMOKE_STATE_FILE = STATE_FILE; - - try { - const resolvedTmp = path.resolve(TMP_DIR); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); - - if (fs.existsSync(TMP_DIR)) { - if (isInsideTemp) { - fs.rmSync(TMP_DIR, { recursive: true, force: true }); - } else { - console.warn('[setup] Skipping TMP_DIR cleanup outside os.tmpdir():', resolvedTmp); - } - } - ensurePrivateDir(TMP_DIR); - ensurePrivateDir(DB_PATH); - writeState(state); - - console.log('[setup] Generating FROSTR credentials...'); - const iglooCore = await import('@frostr/igloo-core') as typeof import('@frostr/igloo-core'); - const { generateKeysetWithSecret, decodeGroup } = iglooCore; - - const { groupCredential, shareCredentials } = generateKeysetWithSecret(2, 2, TEST_NSEC_HEX); - if ( - !Array.isArray(shareCredentials) || - shareCredentials.length < 2 || - typeof shareCredentials[0] !== 'string' || - typeof shareCredentials[1] !== 'string' - ) { - throw new Error( - `Invalid keyset from generateKeysetWithSecret: shareCredentials.length=${Array.isArray(shareCredentials) ? shareCredentials.length : 'non-array'} ` + - `shareCredentials=${JSON.stringify(shareCredentials)} groupCredentialType=${typeof groupCredential}` - ); - } - const group = decodeGroup(groupCredential); - const groupPubkeyHex = group.group_pk.replace(/^(02|03)/, ''); - state.groupCredential = groupCredential; - state.shareCredentials = shareCredentials; - state.groupPubkeyHex = groupPubkeyHex; - writeState(state); - - console.log('[setup] Starting igloo-server on port', port, '...'); - serverProcess = spawnDetached( - 'bun', - ['run', 'src/server.ts'], - { - ADMIN_SECRET, - DB_PATH, - HOST_PORT: String(port), - HOST_NAME: host, - RATE_LIMIT_ENABLED: 'false', - SKIP_RELAY_PROBE: 'true', - SKIP_STARTUP_ECHO: 'true', - NODE_ENV: 'test', - AUTH_ENABLED: 'true', - FROSTR_SIGN_TIMEOUT: '5000', - UI_EVENT_LOG_INCLUDE_PINGS: 'false', - UPDATE_CHECK_DISABLED: 'true', - ALLOW_LOCALHOST_RELAY: 'true', - // Clear any .env credentials so the server starts without pre-loaded creds - GROUP_CRED: '', - SHARE_CRED: '', - RELAYS: '', - }, - SERVER_LOG, - ); - state.serverPid = serverProcess.pid ?? 0; - writeState(state); - - await waitForHttp(`${baseUrl}/api/onboarding/status`, 20_000); - console.log('[setup] Server is up.'); - - console.log('[setup] Running onboarding...'); - api = await request.newContext({ baseURL: baseUrl }); - - let res = await api.post('/api/onboarding/validate-admin', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - }); - if (!res.ok()) throw new Error(`validate-admin failed ${res.status()}: ${await res.text()}`); - - res = await api.post('/api/onboarding/setup', { - headers: { Authorization: `Bearer ${ADMIN_SECRET}` }, - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`setup failed ${res.status()}: ${await res.text()}`); - - console.log('[setup] Logging in...'); - res = await api.post('/api/auth/login', { - data: { username: ADMIN_USERNAME, password: ADMIN_PASSWORD }, - }); - if (!res.ok()) throw new Error(`login failed ${res.status()}: ${await res.text()}`); - const { sessionId } = (await res.json()) as { sessionId: string }; - state.sessionId = sessionId; - writeState(state); - - console.log('[setup] Setting FROSTR credentials on server...'); - res = await api.post('/api/user/credentials', { - headers: { 'X-Session-ID': sessionId }, - data: { - group_cred: groupCredential, - share_cred: shareCredentials[0], - relays: [`ws://${host}:${port}`], - }, - }); - if (!res.ok()) throw new Error(`set-credentials failed ${res.status()}: ${await res.text()}`); - - await pollUntil( - async () => { - const s = await api!.get('/api/status', { headers: { 'X-Session-ID': sessionId } }); - if (!s.ok()) return false; - const body = (await s.json()) as { nodeActive: boolean }; - return body.nodeActive === true; - }, - 15_000, - 1000, - 'nodeActive = true', - ); - console.log('[setup] Node is active.'); - - console.log('[setup] Starting co-signer with shareCredentials[1]...'); - cosignerProcess = spawnDetached( - 'node', - [ - path.resolve('tests/e2e/cosigner.mjs'), - groupCredential, - shareCredentials[1], - `ws://${host}:${port}`, - ], - {}, - COSIGNER_LOG, - ); - state.cosignerPid = cosignerProcess.pid ?? 0; - writeState(state); - console.log('[setup] Co-signer pid:', cosignerProcess.pid); - - console.log('[setup] Probing signing (waiting for co-signer to join relay)...'); - const TEST_MSG = 'a'.repeat(64); - let signOk = false; - for (let attempt = 1; attempt <= 5; attempt++) { - if (cosignerProcess && cosignerProcess.exitCode !== null) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error( - `Co-signer exited early with code ${cosignerProcess.exitCode} before signing was ready.\nCo-signer log:\n${cosLog}` - ); - } - await sleep(3000); - const sr = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: TEST_MSG }, - }).catch(() => null); - if (sr && sr.ok()) { - signOk = true; - console.log(`[setup] Signing OK on attempt ${attempt}.`); - break; - } - const errBody = sr ? await sr.text().catch(() => '(unreadable)') : '(no response)'; - console.log(`[setup] Signing attempt ${attempt} failed (${sr?.status() ?? 'err'}): ${errBody.slice(0, 200)}`); - } - if (!signOk) { - const cosLog = fs.existsSync(COSIGNER_LOG) ? fs.readFileSync(COSIGNER_LOG, 'utf8') : '(empty)'; - throw new Error(`Co-signer did not become ready within probe window.\nCo-signer log:\n${cosLog}`); - } - - console.log('[setup] Creating test API key...'); - res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'smoke-test-key' }, - }); - if (res.ok()) { - const body = (await res.json()) as { apiKey: { token: string; id: string | number } }; - state.apiKey = body.apiKey.token; - state.apiKeyId = String(body.apiKey.id); - } else { - console.warn('[setup] Could not create API key - auth tests will skip API-key checks.'); - } - - await api.dispose(); - api = null; - - writeState(state); - console.log('[setup] Global setup complete. State saved to', STATE_FILE); - } catch (err) { - if (api) { - try { - await api.dispose(); - } catch { - // no-op - } - } - - terminateProcess(cosignerProcess, 'co-signer'); - terminateProcess(serverProcess, 'server'); - - // Persist whatever we have so teardown can still clean up. - try { - writeState(state); - } catch { - // no-op - } - throw err; - } -} diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts deleted file mode 100644 index 071cf3b..0000000 --- a/tests/e2e/global-teardown.ts +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Playwright global teardown – kills the server + co-signer and cleans up - * the temp directory created by global-setup. - */ - -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import { execFileSync } from 'child_process'; -import type { FullConfig } from '@playwright/test'; -import type { SmokeTestState } from './state.js'; - -const MAX_STATE_AGE_MS = 10 * 60 * 1000; - -function parsePositivePid(raw: unknown, fieldName: string): number | null { - if (raw == null || raw === 0) return null; - if (typeof raw !== 'number' || !Number.isInteger(raw) || raw <= 0) { - console.warn(`[teardown] Invalid ${fieldName}; expected positive integer PID, got:`, raw); - return null; - } - return raw; -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === 'EPERM') return true; - return false; - } -} - -function getProcessCommand(pid: number): string | null { - try { - const output = execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - const cmd = output.trim(); - return cmd.length > 0 ? cmd : null; - } catch { - return null; - } -} - -function validateProcessIdentity(pid: number, label: 'co-signer' | 'server'): boolean { - const command = getProcessCommand(pid); - if (!command) { - console.warn(`[teardown] Could not read command line for ${label} pid ${pid}; skipping SIGTERM for safety.`); - return false; - } - - const expectedFragment = label === 'co-signer' ? 'tests/e2e/cosigner.mjs' : 'src/server.ts'; - const matches = command.includes(expectedFragment); - if (!matches) { - console.warn( - `[teardown] ${label} pid ${pid} command did not match expected identity (${expectedFragment}); ` + - `actual="${command}". Skipping SIGTERM for safety.` - ); - } - return matches; -} - -function resolveSafeTmpDir(rawTmpDir: unknown): string | null { - if (typeof rawTmpDir !== 'string' || rawTmpDir.trim().length === 0) { - console.warn('[teardown] Invalid tmpDir in state; skipping temp cleanup.'); - return null; - } - const resolvedTmp = path.resolve(rawTmpDir); - const tempRoot = path.resolve(os.tmpdir()); - const relToTempRoot = path.relative(tempRoot, resolvedTmp); - const isInsideTemp = - relToTempRoot.length > 0 && - relToTempRoot !== '.' && - !relToTempRoot.startsWith('..') && - !path.isAbsolute(relToTempRoot); - if (!isInsideTemp) { - console.warn('[teardown] Skipping temp dir removal outside os.tmpdir():', resolvedTmp); - return null; - } - if (!path.basename(resolvedTmp).startsWith('igloo-smoke-test-')) { - console.warn('[teardown] Refusing to remove unexpected temp dir name:', resolvedTmp); - return null; - } - try { - if (!fs.existsSync(resolvedTmp)) { - console.warn('[teardown] tmpDir does not exist; skipping temp cleanup:', resolvedTmp); - return null; - } - if (!fs.statSync(resolvedTmp).isDirectory()) { - console.warn('[teardown] tmpDir is not a directory; skipping temp cleanup:', resolvedTmp); - return null; - } - } catch (error) { - console.warn('[teardown] Could not validate tmpDir; skipping temp cleanup:', error); - return null; - } - return resolvedTmp; -} - -export default async function globalTeardown(_config: FullConfig): Promise { - const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile || stateFile.trim().length === 0) { - throw new Error('[teardown] SMOKE_STATE_FILE is required; refusing to guess a state file.'); - } - - const resolvedStateFile = path.resolve(stateFile); - console.log('[teardown] Resolved state file:', resolvedStateFile); - - if (!fs.existsSync(resolvedStateFile)) { - throw new Error(`[teardown] State file does not exist: ${resolvedStateFile}`); - } - - let skipTempCleanup = false; - try { - const ageMs = Date.now() - fs.statSync(resolvedStateFile).mtimeMs; - if (ageMs > MAX_STATE_AGE_MS) { - console.warn( - `[teardown] State file is stale (${Math.round(ageMs / 1000)}s old); ` + - 'skipping temp cleanup, but continuing process teardown to avoid leaks.' - ); - skipTempCleanup = true; - } - } catch (error) { - console.warn('[teardown] Could not stat state file; skipping cleanup for safety:', error); - return; - } - - let parsedState: Partial; - try { - parsedState = JSON.parse(fs.readFileSync(resolvedStateFile, 'utf8')) as Partial; - } catch (error) { - console.warn('[teardown] Could not parse state file; skipping cleanup for safety:', error); - return; - } - - const cosignerPid = parsePositivePid(parsedState.cosignerPid, 'cosignerPid'); - const serverPid = parsePositivePid(parsedState.serverPid, 'serverPid'); - const safeTmpDir = skipTempCleanup ? null : resolveSafeTmpDir(parsedState.tmpDir); - - for (const [label, pid] of [['co-signer', cosignerPid], ['server', serverPid]] as const) { - if (!pid) continue; - if (!isProcessRunning(pid)) { - console.warn(`[teardown] ${label} pid ${pid} is not running; skipping SIGTERM.`); - continue; - } - if (!validateProcessIdentity(pid, label)) { - continue; - } - try { - process.kill(pid, 'SIGTERM'); - console.log(`[teardown] Sent SIGTERM to ${label} (pid ${pid})`); - } catch (err: unknown) { - if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { - console.warn(`[teardown] Could not kill ${label} (pid ${pid}):`, err); - } - } - } - - await new Promise(r => setTimeout(r, 500)); - - if (skipTempCleanup) { - console.warn('[teardown] Skipping temp dir removal because state file is stale.'); - return; - } - - if (!safeTmpDir) { - console.warn('[teardown] Skipping temp dir removal due to invalid tmpDir state.'); - return; - } - - try { - fs.rmSync(safeTmpDir, { recursive: true, force: true }); - console.log('[teardown] Removed temp dir', safeTmpDir); - } catch (err) { - console.warn('[teardown] Could not remove temp dir:', err); - } -} diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts deleted file mode 100644 index c0a8cd7..0000000 --- a/tests/e2e/helpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from '@playwright/test'; -import type { Page } from '@playwright/test'; - -export async function loginAs(page: Page, username: string, password: string): Promise { - const usernameField = page - .locator('input[autocomplete="username"], input[id*="user" i], input[name*="user" i], input[placeholder*="user" i]') - .first(); - const passwordField = page.locator('input[type="password"]').first(); - const submitBtn = page.getByRole('button', { name: /login|sign in/i }).first(); - - await usernameField.fill(username); - await passwordField.fill(password); - await submitBtn.click(); - await expect( - page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first(), - 'login failed: expected dashboard tabs after submit' - ).toBeVisible({ timeout: 10_000 }); -} diff --git a/tests/e2e/smoke-test-defaults.json b/tests/e2e/smoke-test-defaults.json deleted file mode 100644 index 2c2419e..0000000 --- a/tests/e2e/smoke-test-defaults.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "testNsecHex": "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" -} diff --git a/tests/e2e/specs/01-auth.e2e.ts b/tests/e2e/specs/01-auth.e2e.ts deleted file mode 100644 index d10a25d..0000000 --- a/tests/e2e/specs/01-auth.e2e.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Auth smoke tests – login, logout, session auth, API-key auth, 401 enforcement. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, apiKey, adminUsername, adminPassword } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Auth – /api/auth', () => { - test('GET /api/auth/status returns enabled methods', async () => { - await withApi(async (api) => { - const res = await api.get('/api/auth/status'); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(typeof body).toBe('object'); - }); - }); - - test('POST /api/auth/login – valid credentials return sessionId', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('sessionId'); - expect(typeof body.sessionId).toBe('string'); - expect(body.sessionId.length).toBeGreaterThan(8); - }); - }); - - test('POST /api/auth/login – wrong password returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: adminUsername, password: 'WrongPass@1' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('POST /api/auth/login – unknown user returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/auth/login', { - data: { username: 'nobody', password: 'WrongPass@1' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/peers – no auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/status – valid session returns 200', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/event-log – valid API key (X-API-Key) returns 200', async () => { - test.skip(!apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey! }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/event-log – valid API key (Bearer) returns 200', async () => { - test.skip(!apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { Authorization: `Bearer ${apiKey!}` }, - }); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/peers – invalid API key returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers', { - headers: { 'X-API-Key': 'totally-invalid-key' }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('POST /api/auth/logout – returns 200 and clears session', async () => { - // Log in fresh so we don't burn the shared session - await withApi(async (api) => { - const loginRes = await api.post('/api/auth/login', { - data: { username: adminUsername, password: adminPassword }, - }); - expect(loginRes.status()).toBe(200); - const { sessionId: tempSession } = await loginRes.json(); - - const logoutRes = await api.post('/api/auth/logout', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(logoutRes.status()).toBe(200); - - // The session should now be invalid - const afterRes = await api.get('/api/peers', { - headers: { 'X-Session-ID': tempSession }, - }); - expect(afterRes.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/02-status-peers.e2e.ts b/tests/e2e/specs/02-status-peers.e2e.ts deleted file mode 100644 index 7ebb7e7..0000000 --- a/tests/e2e/specs/02-status-peers.e2e.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Status and peers smoke tests. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -let state: SmokeTestState; -let baseUrl = ''; -let sessionId = ''; -let groupPubkeyHex = ''; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.beforeAll(() => { - state = loadState(); - baseUrl = state.baseUrl; - sessionId = state.sessionId; - groupPubkeyHex = state.groupPubkeyHex; -}); - -test.describe('Status – /api/status', () => { - test('GET /api/status is publicly accessible without auth', async () => { - // /api/status intentionally allows unauthenticated health checks - await withApi(async (api) => { - const res = await api.get('/api/status'); - expect(res.status()).toBe(200); - }); - }); - - test('GET /api/status returns 200 with node info', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body.serverRunning).toBe(true); - expect(body.nodeActive).toBe(true); - expect(body).toHaveProperty('health'); - expect(body).toHaveProperty('relayCount'); - expect(body).toHaveProperty('timestamp'); - }); - }); - - test('GET /api/status has valid health object', async () => { - await withApi(async (api) => { - const res = await api.get('/api/status', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.health).toHaveProperty('isConnected'); - expect(typeof body.health.isConnected).toBe('boolean'); - expect(body.health).toHaveProperty('consecutiveConnectivityFailures'); - }); - }); -}); - -test.describe('Peers – /api/peers', () => { - test('GET /api/peers returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/peers returns peer list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('peers'); - expect(Array.isArray(body.peers)).toBe(true); - // 2-of-2 keyset: 1 remote peer (self filtered out) - expect(body.peers.length).toBeGreaterThanOrEqual(1); - expect(typeof body.total).toBe('number'); - expect(typeof body.online).toBe('number'); - }); - }); - - test('GET /api/peers/group returns group pubkey', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers/group', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(body.pubkey).toBe(groupPubkeyHex); - expect(typeof body.threshold).toBe('number'); - }); - }); - - test('GET /api/peers/self returns own share pubkey', async () => { - await withApi(async (api) => { - const res = await api.get('/api/peers/self', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('pubkey'); - expect(typeof body.pubkey).toBe('string'); - }); - }); -}); diff --git a/tests/e2e/specs/03-nip44-nip04.e2e.ts b/tests/e2e/specs/03-nip44-nip04.e2e.ts deleted file mode 100644 index 4657b49..0000000 --- a/tests/e2e/specs/03-nip44-nip04.e2e.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * NIP-44 and NIP-04 encrypt/decrypt smoke tests. - * - * We use the group pubkey as the "peer" for ECDH operations – the server - * holds share-0 so it can derive the shared secret with any co-participant. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, groupPubkeyHex } = state; - -const PLAINTEXT = 'Hello from igloo smoke test!'; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -// ─── NIP-44 ────────────────────────────────────────────────────────────────── - -test.describe('NIP-44 – /api/nip44', () => { - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('encrypt returns ciphertext', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - expect(typeof body.result).toBe('string'); - expect(body.result.length).toBeGreaterThan(0); - }); - }); - - test('encrypt then decrypt round-trips plaintext', async () => { - await withApi(async (api) => { - const encRes = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip44/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - }); - }); - - test('invalid peer_pubkey returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('missing content returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip44/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, - }); - expect(res.status()).toBe(400); - }); - }); -}); - -// ─── NIP-04 ────────────────────────────────────────────────────────────────── - -test.describe('NIP-04 – /api/nip04', () => { - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('encrypt returns ciphertext with IV suffix', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('result'); - // NIP-04 ciphertext has the form ?iv= - expect(body.result).toMatch(/\?iv=/); - }); - }); - - test('encrypt then decrypt round-trips plaintext', async () => { - await withApi(async (api) => { - const encRes = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: PLAINTEXT }, - }); - expect(encRes.status()).toBe(200); - const { result: ciphertext } = await encRes.json(); - - const decRes = await api.post('/api/nip04/decrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex, content: ciphertext }, - }); - expect(decRes.status()).toBe(200); - const { result: plaintext } = await decRes.json(); - expect(plaintext).toBe(PLAINTEXT); - }); - }); - - test('invalid peer_pubkey returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: 'not-a-valid-pubkey', content: PLAINTEXT }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('missing content returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/nip04/encrypt', { - headers: { 'X-Session-ID': sessionId }, - data: { peer_pubkey: groupPubkeyHex }, - }); - expect(res.status()).toBe(400); - }); - }); -}); diff --git a/tests/e2e/specs/04-sign.e2e.ts b/tests/e2e/specs/04-sign.e2e.ts deleted file mode 100644 index 36b5ebd..0000000 --- a/tests/e2e/specs/04-sign.e2e.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Signing smoke tests – requires the igloo-cli co-signer launched in global setup. - * - * sign timeout: 5 s (FROSTR_SIGN_TIMEOUT env set in global-setup for smoke runs). - * Test timeout overridden to 30 s to accommodate the signing round-trip. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -type SignEventPayload = { - pubkey: string; - kind: number; - created_at: number; - content: string; - tags: string[][]; -}; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, groupPubkeyHex } = state; - -// Valid 32-byte hex event IDs for signing -const EVENT_ID_A = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const EVENT_ID_B = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const SIGNATURE_REGEX = /^[0-9a-f]{128}$/i; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Sign – /api/sign', () => { - // Explicit per-suite timeout for signing flows; global timeout is also 30_000. - test.setTimeout(30_000); - - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - data: { message: EVENT_ID_A }, - }); - expect(res.status()).toBe(401); - }); - }); - - test('returns 400 for invalid (non-hex) message', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'not-hex' }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('returns 400 for message shorter than 32 bytes', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: 'deadbeef' }, // only 4 bytes - }); - expect(res.status()).toBe(400); - }); - }); - - test('returns 400 for missing body', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: {}, - }); - expect(res.status()).toBe(400); - }); - }); - - test('signs a 32-byte hex message and returns signature', async () => { - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: EVENT_ID_A }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id', EVENT_ID_A); - expect(body).toHaveProperty('signature'); - expect(typeof body.signature).toBe('string'); - // Schnorr signature = 64 bytes = 128 hex chars - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('signs a full event object and returns signature', async () => { - await withApi(async (api) => { - // Use the group pubkey as the event author pubkey - const event: SignEventPayload = { - pubkey: groupPubkeyHex, - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'igloo smoke test', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { event }, - }); - expect(res.status()).toBe(200); - - const body = await res.json(); - expect(body).toHaveProperty('id'); - expect(body).toHaveProperty('signature'); - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('signing works with API key auth', async () => { - test.skip(!state.apiKey, 'No API key available'); - await withApi(async (api) => { - const res = await api.post('/api/sign', { - headers: { 'X-API-Key': state.apiKey! }, - data: { message: EVENT_ID_B }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.signature).toMatch(SIGNATURE_REGEX); - }); - }); - - test('event with invalid pubkey returns 400', async () => { - await withApi(async (api) => { - const invalidEvent: SignEventPayload = { - pubkey: 'not-64-hex', - kind: 1, - created_at: Math.floor(Date.now() / 1000), - content: 'bad', - tags: [], - }; - const res = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { - event: invalidEvent, - }, - }); - expect(res.status()).toBe(400); - }); - }); -}); diff --git a/tests/e2e/specs/05-admin.e2e.ts b/tests/e2e/specs/05-admin.e2e.ts deleted file mode 100644 index 5785ceb..0000000 --- a/tests/e2e/specs/05-admin.e2e.ts +++ /dev/null @@ -1,181 +0,0 @@ -/** - * Admin endpoint smoke tests: - * - API key creation, listing, revocation - * - User listing, whoami - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId, adminUsername } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Admin – API keys', () => { - test('GET /api/admin/api-keys returns list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('apiKeys'); - expect(Array.isArray(body.apiKeys)).toBe(true); - // At minimum the key created in global setup should be here - expect(body.apiKeys.length).toBeGreaterThanOrEqual(1); - }); - }); - - test('POST /api/admin/api-keys creates a new key', async () => { - await withApi(async (api) => { - let createdKeyId: string | number | null = null; - try { - const res = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: `temp-test-key-${Date.now()}` }, - }); - expect(res.status()).toBe(201); - const body = await res.json(); - expect(body).toHaveProperty('apiKey'); - expect(body.apiKey).toHaveProperty('token'); - expect(typeof body.apiKey.token).toBe('string'); - expect(body.apiKey.token.length).toBeGreaterThan(20); - expect(body.apiKey).toHaveProperty('id'); - createdKeyId = body.apiKey.id; - } finally { - if (createdKeyId !== null) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('new API key can authenticate', async () => { - await withApi(async (api) => { - let createdKeyId: string | number | null = null; - try { - // Create key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: `auth-test-key-${Date.now()}` }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - createdKeyId = apiKey.id; - - // Use key to hit an auth-protected route. - const authRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': apiKey.token }, - }); - expect(authRes.status()).toBe(200); - } finally { - if (createdKeyId !== null) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdKeyId, reason: 'smoke-test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('revoked API key returns 401', async () => { - await withApi(async (api) => { - let createdApiKey: { id: string | number; token: string } | null = null; - try { - // Create a fresh key - const createRes = await api.post('/api/admin/api-keys', { - headers: { 'X-Session-ID': sessionId }, - data: { label: 'revoke-test-key' }, - }); - expect(createRes.status()).toBe(201); - const { apiKey } = await createRes.json(); - createdApiKey = apiKey; - - // Verify it works on an auth-protected endpoint - const beforeRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': createdApiKey.token }, - }); - expect(beforeRes.status()).toBe(200); - - // Revoke it - const revokeRes = await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdApiKey.id, reason: 'smoke-test cleanup' }, - }); - expect(revokeRes.status()).toBe(200); - - // Now the revoked key should be rejected - const afterRes = await api.get('/api/event-log', { - headers: { 'X-API-Key': createdApiKey.token }, - }); - expect(afterRes.status()).toBe(401); - } finally { - if (createdApiKey) { - await api.post('/api/admin/api-keys/revoke', { - headers: { 'X-Session-ID': sessionId }, - data: { apiKeyId: createdApiKey.id, reason: 'test cleanup' }, - }).catch(() => null); - } - } - }); - }); - - test('GET /api/admin/api-keys without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/api-keys'); - expect(res.status()).toBe(401); - }); - }); -}); - -test.describe('Admin – Users', () => { - test('GET /api/admin/users returns user list', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/users', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('users'); - expect(Array.isArray(body.users)).toBe(true); - expect(body.users.length).toBeGreaterThanOrEqual(1); - // Our admin user must be in the list - const found = body.users.some((u: { username: string }) => u.username === adminUsername); - expect(found).toBe(true); - }); - }); - - test('GET /api/admin/whoami returns admin identity', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/whoami', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('admin', true); - expect(body).toHaveProperty('userId'); - expect(body.userId).not.toBeNull(); - }); - }); - - test('GET /api/admin/users without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/admin/users'); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/06-event-log.e2e.ts b/tests/e2e/specs/06-event-log.e2e.ts deleted file mode 100644 index 6199100..0000000 --- a/tests/e2e/specs/06-event-log.e2e.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * UI event-log smoke tests. - * This suite seeds its own event-log entries in beforeAll. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Event log – /api/event-log', () => { - test.beforeAll(async () => { - await withApi(async (api) => { - const runUniqueHex = `${Date.now().toString(16)}${Math.random().toString(16).slice(2)}`.padStart(64, 'b').slice(0, 64); - const seedRes = await api.post('/api/sign', { - headers: { 'X-Session-ID': sessionId }, - data: { message: runUniqueHex }, - }); - if (!seedRes.ok()) { - throw new Error(`Failed to seed event log via /api/sign: ${seedRes.status()} ${await seedRes.text()}`); - } - }); - }); - - test('returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/event-log returns entries array', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body).toHaveProperty('entries'); - expect(Array.isArray(body.entries)).toBe(true); - }); - }); - - test('entries have expected shape', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(Array.isArray(body.entries)).toBe(true); - expect(body.entries.length).toBeGreaterThan(0); - const entry = body.entries[0]; - expect(entry).toHaveProperty('type'); - expect(entry).toHaveProperty('message'); - expect(entry).toHaveProperty('timestamp'); - }); - }); - - test('pagination params are accepted', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log?limit=5', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - expect(body.entries.length).toBeLessThanOrEqual(5); - }); - }); - - test('GET /api/event-log/export returns NDJSON', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log/export', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - - // Export returns newline-delimited JSON (application/x-ndjson), not a JSON array - const contentType = res.headers()['content-type'] ?? ''; - expect(contentType).toContain('ndjson'); - - const text = await res.text(); - // Each non-empty line must be valid JSON - const lines = text.trim().split('\n').filter(Boolean); - expect(lines.length).toBeGreaterThan(0); - for (const line of lines) { - expect(() => JSON.parse(line)).not.toThrow(); - } - }); - }); - - test('export without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.get('/api/event-log/export'); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/07-env.e2e.ts b/tests/e2e/specs/07-env.e2e.ts deleted file mode 100644 index 51bc8f4..0000000 --- a/tests/e2e/specs/07-env.e2e.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Credential management smoke tests – /api/env. - * - * GET returns the current credential state. - * POST with invalid credentials returns 400. - * We do NOT update valid credentials here to avoid disrupting co-signer timing. - */ - -import { test, expect, request } from '@playwright/test'; -import type { APIRequestContext } from '@playwright/test'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, sessionId } = state; - -async function withApi(fn: (api: APIRequestContext) => Promise): Promise { - const api = await request.newContext({ baseURL: baseUrl }); - try { - await fn(api); - } finally { - await api.dispose(); - } -} - -test.describe('Env / credentials – /api/env', () => { - test('GET /api/env returns 401 without auth', async () => { - await withApi(async (api) => { - const res = await api.get('/api/env'); - expect(res.status()).toBe(401); - }); - }); - - test('GET /api/env with session returns credential metadata', async () => { - await withApi(async (api) => { - const res = await api.get('/api/env', { - headers: { 'X-Session-ID': sessionId }, - }); - expect(res.status()).toBe(200); - const body = await res.json(); - // In DB mode the response should indicate that credentials are present - expect(body).toHaveProperty('hasCredentials', true); - }); - }); - - test('POST /api/env – invalid GROUP_CRED returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: 'not-a-valid-bfgroup-credential', - SHARE_CRED: state.shareCredentials[0], - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – invalid SHARE_CRED returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: 'not-a-valid-bfshare-credential', - RELAYS: [`ws://127.0.0.1:${state.port}`], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – invalid relay URL returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: ['not-a-websocket-url'], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env – empty RELAYS returns 400', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - headers: { 'X-Session-ID': sessionId }, - data: { - GROUP_CRED: state.groupCredential, - SHARE_CRED: state.shareCredentials[0], - RELAYS: [], - }, - }); - expect(res.status()).toBe(400); - }); - }); - - test('POST /api/env without auth returns 401', async () => { - await withApi(async (api) => { - const res = await api.post('/api/env', { - data: { GROUP_CRED: state.groupCredential, SHARE_CRED: state.shareCredentials[0] }, - }); - expect(res.status()).toBe(401); - }); - }); -}); diff --git a/tests/e2e/specs/08-ui.e2e.ts b/tests/e2e/specs/08-ui.e2e.ts deleted file mode 100644 index 92ac82b..0000000 --- a/tests/e2e/specs/08-ui.e2e.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Browser UI smoke tests – uses Playwright's full browser to exercise the SPA. - * - * Prerequisites: `bun run build` must have been run so static/app.js exists. - * The server is already running (started in global-setup). - */ - -import { test, expect } from '@playwright/test'; -import { loginAs } from '../helpers.js'; -import { loadState } from '../state.js'; -import type { SmokeTestState } from '../state.js'; - -const state: SmokeTestState = loadState(); -const { baseUrl, adminUsername, adminPassword } = state; - -test.describe('UI – Login page', () => { - test('/ renders the login form when not authenticated', async ({ page }) => { - await page.goto(baseUrl); - // The SPA should show either the login form or onboarding - // Since onboarding is complete, we expect the login form - await expect(page).toHaveURL(new URL('/', baseUrl).toString()); - // Login form has username + password inputs - await expect(page.locator('input[type="text"], input[id*="user"], input[name*="user"]').first()).toBeVisible({ - timeout: 10_000, - }); - await expect(page.locator('input[type="password"]').first()).toBeVisible(); - }); - - test('login form accepts credentials and navigates to dashboard', async ({ page }) => { - await page.goto(baseUrl); - await loginAs(page, adminUsername, adminPassword); - - // After login we should see the main app tabs - await expect(page.locator('[role="tab"], .tab, button:has-text("Signer"), button:has-text("Configure")').first()).toBeVisible({ - timeout: 10_000, - }); - }); -}); - -test.describe('UI – Authenticated app', () => { - // Log in once per test block using page fixtures (each test gets a fresh page) - test.beforeEach(async ({ page }) => { - await page.goto(baseUrl); - await loginAs(page, adminUsername, adminPassword); - }); - - test('Signer tab is visible and shows node status indicator', async ({ page }) => { - // The Signer tab or its content should be visible after login - const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); - await expect(signerTab).toBeVisible({ timeout: 8_000 }); - await signerTab.click(); - await expect(page.locator('body')).toContainText(/server signer:\s*(running|starting|stopped)/i, { timeout: 8_000 }); - await expect(page.locator('body')).toContainText(/\bnode\s+(active|inactive)\b/i, { timeout: 8_000 }); - }); - - test('Back to Configure button navigates to configuration page', async ({ page }) => { - // In signer view, config form copy should not be visible yet. - await expect(page.locator('body')).not.toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); - - const backToConfigure = page.locator('button:has-text("Back to Configure")').first(); - await expect(backToConfigure).toBeVisible({ timeout: 8_000 }); - await backToConfigure.click(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('body')).toContainText(/(update signer configuration|configure signer)/i, { timeout: 8_000 }); - }); - - test('API Keys tab is accessible', async ({ page }) => { - const apiKeysTab = page - .locator('[role="tab"]:has-text("API Keys"), [role="tab"]:has-text("Api Keys"), button:has-text("API Keys")') - .first(); - await expect(apiKeysTab).toBeVisible({ timeout: 8_000 }); - await apiKeysTab.click(); - await page.waitForLoadState('networkidle'); - // The tab panel should render without error - await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); - }); - - test('Event Log section is visible on Signer tab and shows no errors', async ({ page }) => { - const signerTab = page.locator('[role="tab"]:has-text("Signer"), button:has-text("Signer"), a:has-text("Signer")').first(); - await expect(signerTab).toBeVisible({ timeout: 8_000 }); - await signerTab.click(); - - // The Event Log is a collapsible section embedded in the Signer tab (not a top-level tab). - // It renders a div with role="button" and a span containing "Event Log". - const eventLogToggle = page.locator('[role="button"]:has-text("Event Log")').first(); - await expect(eventLogToggle).toBeVisible({ timeout: 8_000 }); - await eventLogToggle.click(); - await page.waitForLoadState('networkidle'); - await expect(page.locator('body')).not.toContainText('Something went wrong', { timeout: 5_000 }); - }); - - test('Logout button signs out and returns to login', async ({ page }) => { - // Find and click a logout button - const logoutBtn = page - .locator('button:has-text("Logout"), button:has-text("Sign out"), a:has-text("Logout"), [aria-label*="logout" i]') - .first(); - await expect(logoutBtn).toBeVisible({ timeout: 8_000 }); - await logoutBtn.click(); - await page.waitForLoadState('networkidle'); - - // Should be back at the login form - await expect(page.locator('input[type="password"]').first()).toBeVisible({ timeout: 8_000 }); - }); -}); - -test.describe('UI – Onboarding already completed', () => { - test('/ does not show onboarding when DB is initialised', async ({ page }) => { - await page.goto(baseUrl); - // Specific onboarding copy should not appear once DB is initialized. - await expect(page.locator('body')).not.toContainText(/enter the admin secret to begin setting up your igloo server/i, { timeout: 6_000 }); - await expect(page.locator('body')).not.toContainText(/create your admin account to secure your igloo server/i, { timeout: 6_000 }); - }); -}); diff --git a/tests/e2e/state.ts b/tests/e2e/state.ts deleted file mode 100644 index 8e52078..0000000 --- a/tests/e2e/state.ts +++ /dev/null @@ -1,91 +0,0 @@ -import fs from 'fs'; -import { z } from 'zod'; - -export interface SmokeTestState { - port: number; - baseUrl: string; - tmpDir: string; - serverPid: number; - cosignerPid: number; - sessionId: string; - apiKey: string | null; - apiKeyId: string | null; - groupCredential: string; - shareCredentials: string[]; - groupPubkeyHex: string; // x-only (no 02/03 prefix), used for NIP-44/NIP-04 - adminUsername: string; - adminPassword: string; - adminSecret: string; -} - -/** - * Zod schema for SmokeTestState persisted by global-setup. - * Validates shape and types before returning from loadState. - */ -const SMOKE_TEST_STATE_SCHEMA = z.object({ - port: z.number().int().positive(), - baseUrl: z.string().min(1, 'baseUrl must be non-empty'), - tmpDir: z.string(), - serverPid: z.number().int().nonnegative(), - cosignerPid: z.number().int().nonnegative(), - sessionId: z.string().min(1, 'sessionId must be non-empty'), - apiKey: z.string().nullable(), - apiKeyId: z.string().nullable(), - groupCredential: z.string().min(1, 'groupCredential must be non-empty'), - shareCredentials: z - .array(z.string().min(1, 'share credential must be non-empty')) - .nonempty('shareCredentials must contain at least one credential'), - groupPubkeyHex: z - .string() - .regex(/^[0-9a-fA-F]{64}$/, 'groupPubkeyHex must be exactly 64 hex characters'), - adminUsername: z.string().min(1, 'adminUsername must be non-empty'), - adminPassword: z.string().min(1, 'adminPassword must be non-empty'), - adminSecret: z.string().min(1, 'adminSecret must be non-empty'), -}); - -const STUB: SmokeTestState = { - port: 18002, - baseUrl: 'http://localhost:18002', - tmpDir: '', - serverPid: 0, - cosignerPid: 0, - sessionId: '', - apiKey: null, - apiKeyId: null, - groupCredential: '', - shareCredentials: [], - groupPubkeyHex: '', - adminUsername: '', - adminPassword: '', - adminSecret: '', -}; - -/** - * Load shared test state written by global-setup. - * During test discovery (--list) or if SMOKE_STATE_FILE is not yet set, returns - * a harmless stub so that module-level const initialisations succeed. - * The real values are always present when tests actually execute. - */ -export function loadState(): SmokeTestState { - const stateFile = process.env.SMOKE_STATE_FILE; - if (!stateFile) { - return { - ...STUB, - shareCredentials: [...STUB.shareCredentials], - }; - } - try { - const parsed: unknown = JSON.parse(fs.readFileSync(stateFile, 'utf8')); - const result = SMOKE_TEST_STATE_SCHEMA.safeParse(parsed); - if (!result.success) { - const issues = result.error.issues - .map(i => `${i.path.join('.')}: ${i.message}`) - .join('; '); - throw new Error(`validation failed: ${issues}`); - } - return result.data; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`Invalid smoke test state in ${stateFile}: ${detail}`); - } -} diff --git a/tests/routes/env.db-mode.spec.ts b/tests/routes/env.db-mode.spec.ts index f5e8667..85a87a5 100644 --- a/tests/routes/env.db-mode.spec.ts +++ b/tests/routes/env.db-mode.spec.ts @@ -1,7 +1,4 @@ import { describe, expect, test } from 'bun:test'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; import { runRouteScript, PROJECT_ROOT } from './helpers/script-runner'; function normalizeOptionalEnv(value: unknown): string | undefined { @@ -10,33 +7,10 @@ function normalizeOptionalEnv(value: unknown): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } -function loadFixtureTestKeysetSecret(): string | undefined { - const fixturePath = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', - 'e2e', - 'smoke-test-defaults.json' - ); - if (!fs.existsSync(fixturePath)) return undefined; - try { - const raw: unknown = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); - if (typeof raw !== 'object' || raw === null) return undefined; - const testNsecHex = (raw as Record).testNsecHex; - return typeof testNsecHex === 'string' && testNsecHex.trim().length > 0 ? testNsecHex : undefined; - } catch { - return undefined; - } -} - const TEST_KEYSET_SECRET = normalizeOptionalEnv(process.env.TEST_KEYSET_SECRET) ?? normalizeOptionalEnv(process.env.TEST_NSEC_HEX) ?? - normalizeOptionalEnv(loadFixtureTestKeysetSecret()); -if (!TEST_KEYSET_SECRET) { - throw new Error( - 'TEST_KEYSET_SECRET (or TEST_NSEC_HEX) must be set, or tests/e2e/smoke-test-defaults.json must provide testNsecHex.' - ); -} + 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; describe('DB-mode /api/env behavior', () => { test('rejects non-admin session without ADMIN_SECRET (403)', () => { diff --git a/tests/routes/status-env.spec.ts b/tests/routes/status-env.test.ts similarity index 100% rename from tests/routes/status-env.spec.ts rename to tests/routes/status-env.test.ts From f78ab77eace9b75d5317fd3b2ddd8c2950a1b350 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 17:12:39 -0600 Subject: [PATCH 45/69] fix: bump minimatch override past audit vulnerability --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index f7af0d5..3e8b25b 100644 --- a/bun.lock +++ b/bun.lock @@ -47,7 +47,7 @@ "fast-xml-parser": "^5.3.6", "glob": "^10.5.0", "js-yaml": "^4.1.1", - "minimatch": "^10.2.1", + "minimatch": "^10.2.3", "undici": "^6.23.0", }, "packages": { @@ -545,7 +545,7 @@ "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], diff --git a/package.json b/package.json index 9fa9da0..53ce8af 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,7 @@ }, "overrides": { "glob": "^10.5.0", - "minimatch": "^10.2.1", + "minimatch": "^10.2.3", "js-yaml": "^4.1.1", "undici": "^6.23.0", "ajv": "^8.18.0", From 27a8e24562da9f407bea1575b002e1d87c53efea Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Thu, 26 Feb 2026 17:16:48 -0600 Subject: [PATCH 46/69] fix: apply pending auth env hardening updates --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 38 ++----- .github/workflows/umbrel-dev.yml | 9 +- Dockerfile | 4 +- docs/DEPLOY.md | 4 +- docs/openapi/README.md | 5 +- docs/openapi/openapi.json | 103 +++++++++++++----- docs/openapi/openapi.yaml | 53 ++++++--- env.example | 6 +- frontend/App.tsx | 36 +++--- frontend/components/ApiKeys.tsx | 9 +- frontend/components/Configure.tsx | 68 ++++++++++-- frontend/components/Login.tsx | 28 ++++- frontend/components/NIP46.tsx | 46 +++++--- frontend/components/Onboarding.tsx | 41 ++++--- frontend/components/Recover.tsx | 10 +- frontend/components/Signer.tsx | 38 +++++-- frontend/components/nip46/Permissions.tsx | 4 +- frontend/components/nip46/RelaySettings.tsx | 4 +- frontend/components/ui/event-log.tsx | 8 +- frontend/components/ui/icon-button.tsx | 4 +- frontend/components/ui/peer-list.tsx | 14 +-- frontend/components/ui/tooltip.tsx | 5 +- scripts/fetch-swagger-ui.mjs | 2 +- scripts/patch-zod-compat.mjs | 2 +- src/class/relay.ts | 6 +- src/config/crypto.ts | 5 +- src/const.ts | 3 +- src/db/database.ts | 15 ++- .../20250916_0004_audit_nip46_data_sizes.sql | 28 +++-- src/db/nip46.ts | 59 ++++++---- src/nip46/service.ts | 20 ++-- src/node/manager.ts | 68 ++++++++---- src/routes/admin.ts | 24 ++-- src/routes/auth.ts | 87 ++++++++------- src/routes/env.ts | 36 +++++- src/routes/index.ts | 58 ++++++---- src/routes/nip46.ts | 23 +++- src/routes/onboarding.ts | 6 +- src/routes/peers.ts | 48 ++++---- src/routes/sign.ts | 4 +- src/routes/status.ts | 2 +- src/routes/update.ts | 5 +- src/routes/user.ts | 23 +++- src/utils/rate-limiter.ts | 21 ++-- tests/routes/admin.whoami.session.spec.ts | 9 +- tests/routes/auth.rehydrate.spec.ts | 5 +- tests/routes/body-limit.spec.ts | 4 +- tests/routes/onboarding.spec.ts | 16 ++- tests/routes/status-env.test.ts | 1 + 50 files changed, 740 insertions(+), 381 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec46391..a40223b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ master, dev ] + branches: [ main, dev ] pull_request: - branches: [ master, dev ] + branches: [ main, dev ] jobs: test: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8163bb7..4717255 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ on: jobs: release: runs-on: ubuntu-latest - if: github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' + if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' permissions: contents: write pull-requests: write @@ -126,28 +126,27 @@ jobs: --exclude=.git \ --exclude=release \ --exclude=frontend \ - src static package.json bun.lock tsconfig.json dockerfile compose.yml README.md LICENSE + src static package.json bun.lock tsconfig.json Dockerfile compose.yml README.md LICENSE - name: Create release tag run: | # Create git tag for release (works with branch protection) - # Note: Version changes are not committed back to master due to branch protection + # Note: Version changes are not committed back to main due to branch protection # The release archives will contain the correct versions git tag ${{ steps.new_version.outputs.new_version }} git push origin ${{ steps.new_version.outputs.new_version }} - - name: Create GitHub Release - uses: actions/create-release@v1 - id: create_release + - name: Create GitHub release and upload assets + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: tag_name: ${{ steps.new_version.outputs.new_version }} - release_name: Release ${{ steps.new_version.outputs.new_version }} + name: Release ${{ steps.new_version.outputs.new_version }} body: | ## Changes in ${{ steps.new_version.outputs.new_version }} - See [CHANGELOG.md](https://github.com/FROSTR-ORG/igloo-server/blob/master/CHANGELOG.md) for full details. + See [CHANGELOG.md](https://github.com/FROSTR-ORG/igloo-server/blob/main/CHANGELOG.md) for full details. ### Installation @@ -166,26 +165,9 @@ jobs: ``` draft: false prerelease: false - - - name: Upload source archive - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release/igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz - asset_name: igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz - asset_content_type: application/gzip - - - name: Upload binary archive - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./release/igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz - asset_name: igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz - asset_content_type: application/gzip + files: | + ./release/igloo-server-${{ steps.new_version.outputs.version_number }}-src.tar.gz + ./release/igloo-server-${{ steps.new_version.outputs.version_number }}.tar.gz docker: runs-on: ubuntu-latest diff --git a/.github/workflows/umbrel-dev.yml b/.github/workflows/umbrel-dev.yml index 706ec5a..669a49b 100644 --- a/.github/workflows/umbrel-dev.yml +++ b/.github/workflows/umbrel-dev.yml @@ -3,7 +3,7 @@ name: Umbrel Dev Image on: push: branches: - - master + - main - dev workflow_dispatch: @@ -39,6 +39,12 @@ jobs: - name: Smoke test Umbrel image run: | + set -e + cleanup() { + docker rm -f umbrel-dev-test >/dev/null 2>&1 || true + } + trap cleanup EXIT + docker run -d --name umbrel-dev-test -p 8003:8002 \ -e ADMIN_SECRET=ci-admin-secret \ -e ALLOWED_ORIGINS=http://localhost:8003 \ @@ -46,4 +52,3 @@ jobs: igloo-server-umbrel:dev-ci sleep 12 curl -f http://localhost:8003/api/status - docker rm -f umbrel-dev-test diff --git a/Dockerfile b/Dockerfile index 0052609..f918c40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Multi-stage build for smaller production image -FROM oven/bun:1.3.3 AS build +FROM oven/bun:1.3.10 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # --- Production stage --- -FROM oven/bun:1.3.3 AS production +FROM oven/bun:1.3.10 AS production WORKDIR /app diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 00acb3f..ba2391c 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -62,7 +62,9 @@ Note: `env_file: .env` injects values only at container start. If you also bind- 4) Firewall (UFW): ```bash -sudo ufw allow 80 443 22 +sudo ufw allow 22 +sudo ufw allow 80 +sudo ufw allow 443 sudo ufw allow 8002 # only if accessing without reverse proxy sudo ufw enable ``` diff --git a/docs/openapi/README.md b/docs/openapi/README.md index b9fecba..c6069d5 100644 --- a/docs/openapi/README.md +++ b/docs/openapi/README.md @@ -39,8 +39,9 @@ Mode and auth nuance: ### Authentication for API Documentation -- **Development**: No authentication required for easy testing -- **Production**: Authentication required for security (enforced by the server) +Authentication behavior is configuration-dependent: +- **Development**: authentication is commonly disabled by default for local testing, but controlled by environment variables (see `AUTH_ENABLED` in `env.example`) +- **Production**: authentication should be enabled and enforced by server configuration To authenticate in Swagger UI: 1. Use the "Authorize" button in Swagger UI diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 2ac19fa..2653b2c 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -442,6 +442,7 @@ "tags": [ "Authentication" ], + "security": [], "requestBody": { "required": true, "content": { @@ -524,6 +525,7 @@ "tags": [ "Authentication" ], + "security": [], "responses": { "200": { "description": "Logout successful", @@ -1384,15 +1386,36 @@ "$ref": "#/components/schemas/StoredShare" } }, - "example": [ - { - "shareCredential": "bfshare1qqsqp...", - "groupCredential": "bfgroup1qqsqp...", - "savedAt": "2025-01-20T12:00:00.000Z", - "id": "env-stored-share", - "source": "environment" + "examples": { + "metadataOnly": { + "summary": "Default response with credential presence flags only", + "value": [ + { + "hasShareCredential": true, + "hasGroupCredential": true, + "isValid": true, + "savedAt": "2025-01-20T12:00:00.000Z", + "id": "env-stored-share", + "source": "environment" + } + ] + }, + "includesRaw": { + "summary": "Debug response with raw credentials included", + "value": [ + { + "hasShareCredential": true, + "hasGroupCredential": true, + "isValid": true, + "shareCredential": "bfshare1qqsqp...", + "groupCredential": "bfgroup1qqsqp...", + "savedAt": "2025-01-20T12:00:00.000Z", + "id": "env-stored-share", + "source": "environment" + } + ] } - ] + } } } }, @@ -3000,13 +3023,13 @@ "methods": { "type": "array", "items": { - "type": "string", - "enum": [ - "api-key", - "basic-auth", - "session" - ] - }, + "type": "string", + "enum": [ + "api-key", + "basic-auth", + "session" + ] + }, "description": "Available authentication methods" }, "rateLimiting": { @@ -3210,16 +3233,37 @@ "StoredShare": { "type": "object", "properties": { + "hasShareCredential": { + "type": "boolean", + "description": "Whether a share credential is currently stored" + }, + "hasGroupCredential": { + "type": "boolean", + "description": "Whether a group credential is currently stored" + }, + "isValid": { + "type": "boolean", + "description": "Whether both credentials are present and usable together" + }, "shareCredential": { - "type": "string", - "description": "The share credential" + "type": [ + "string", + "null" + ], + "description": "Optional raw share credential (only returned in explicit debug mode)" }, "groupCredential": { - "type": "string", - "description": "The group credential" + "type": [ + "string", + "null" + ], + "description": "Optional raw group credential (only returned in explicit debug mode)" }, "savedAt": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", "description": "When the share was saved" }, @@ -3233,8 +3277,9 @@ } }, "required": [ - "shareCredential", - "groupCredential", + "hasShareCredential", + "hasGroupCredential", + "isValid", "savedAt", "id", "source" @@ -4064,12 +4109,12 @@ ] }, "example": { - "error": "Authentication required", - "authMethods": [ - "api-key", - "basic-auth", - "session" - ] + "error": "Authentication required", + "authMethods": [ + "api-key", + "basic-auth", + "session" + ] } } } @@ -4179,4 +4224,4 @@ } } } -} +} \ No newline at end of file diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index b654aad..c2eea82 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -342,6 +342,7 @@ paths: description: Login with username/password or API key to get a session tags: - Authentication + security: [] requestBody: required: true content: @@ -394,6 +395,7 @@ paths: description: Clear the current session tags: - Authentication + security: [] responses: '200': description: Logout successful @@ -947,12 +949,27 @@ paths: type: array items: $ref: '#/components/schemas/StoredShare' - example: - - shareCredential: "bfshare1qqsqp..." - groupCredential: "bfgroup1qqsqp..." - savedAt: "2025-01-20T12:00:00.000Z" - id: "env-stored-share" - source: "environment" + examples: + metadataOnly: + summary: Default response with credential presence flags only + value: + - hasShareCredential: true + hasGroupCredential: true + isValid: true + savedAt: "2025-01-20T12:00:00.000Z" + id: "env-stored-share" + source: "environment" + includesRaw: + summary: Debug response with raw credentials included + value: + - hasShareCredential: true + hasGroupCredential: true + isValid: true + shareCredential: "bfshare1qqsqp..." + groupCredential: "bfgroup1qqsqp..." + savedAt: "2025-01-20T12:00:00.000Z" + id: "env-stored-share" + source: "environment" '401': $ref: '#/components/responses/Unauthorized' '500': @@ -2100,14 +2117,23 @@ components: StoredShare: type: object properties: + hasShareCredential: + type: boolean + description: Whether a share credential is currently stored + hasGroupCredential: + type: boolean + description: Whether a group credential is currently stored + isValid: + type: boolean + description: Whether both credentials are present and usable together shareCredential: - type: string - description: The share credential + type: ["string", "null"] + description: Optional raw share credential (only returned in explicit debug mode) groupCredential: - type: string - description: The group credential + type: ["string", "null"] + description: Optional raw group credential (only returned in explicit debug mode) savedAt: - type: string + type: ["string", "null"] format: date-time description: When the share was saved id: @@ -2117,8 +2143,9 @@ components: type: string description: Source of the share (e.g., "environment") required: - - shareCredential - - groupCredential + - hasShareCredential + - hasGroupCredential + - isValid - savedAt - id - source diff --git a/env.example b/env.example index 03a0b80..9eff174 100644 --- a/env.example +++ b/env.example @@ -252,7 +252,7 @@ NODE_ENV=development # PERSONAL (Home server - medium security) # AUTH_ENABLED=true # API_KEY=personal-server-key-2024 -# SESSION_SECRET=personal-session-secret-32chars +# SESSION_SECRET=4f7c1e9a2b3d4c5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6 # SESSION_TIMEOUT=7200 # RATE_LIMIT_MAX=50 @@ -261,7 +261,7 @@ NODE_ENV=development # BASIC_AUTH_USER=teamadmin # BASIC_AUTH_PASS=SecureTeamPassword123! # API_KEY=team-automation-key-64chars -# SESSION_SECRET=team-session-secret-32plus-chars +# SESSION_SECRET=9a8b7c6d5e4f3021a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0 # SESSION_TIMEOUT=3600 # RATE_LIMIT_MAX=100 @@ -271,7 +271,7 @@ NODE_ENV=development # BASIC_AUTH_USER=prodadmin # BASIC_AUTH_PASS=VerySecurePassword456! # API_KEY=prod-api-key-with-64-random-chars-abcdef123456789 -# SESSION_SECRET=prod-session-secret-256-bits-of-entropy-required-for-production +# SESSION_SECRET=0123456789abcdef0123456789abcdefabcdef0123456789abcdef0123456789 # SESSION_TIMEOUT=1800 # RATE_LIMIT_ENABLED=true # RATE_LIMIT_WINDOW=300 diff --git a/frontend/App.tsx b/frontend/App.tsx index 8874f96..0ad15c7 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useMemo } from "react" +import React, { useState, useEffect, useRef, useMemo, useCallback } from "react" import Configure from "./components/Configure" import Signer from "./components/Signer" import Recover from "./components/Recover" @@ -87,18 +87,6 @@ const App: React.FC = () => { }; }, []); - // Global handler for authentication/credentials expiry from child components - useEffect(() => { - const onAuthExpired = () => { - // If already unauthenticated, ignore; otherwise trigger logout to show login screen - if (authState.isAuthenticated) { - handleLogout().catch(console.error); - } - }; - window.addEventListener('authExpired', onAuthExpired as EventListener); - return () => window.removeEventListener('authExpired', onAuthExpired as EventListener); - }, [authState.isAuthenticated]); - const initializeApp = async () => { try { // Check onboarding status first with retry logic @@ -333,7 +321,7 @@ const App: React.FC = () => { } }; - const getAuthHeaders = (): Record => { + const getAuthHeaders = useCallback((): Record => { const headers: Record = {}; // Try session-based auth first @@ -351,13 +339,13 @@ const App: React.FC = () => { } return headers; - }; + }, [authState.sessionId, authState.apiKey, authState.basicAuth]); // Memoize auth headers to prevent unnecessary re-renders in child components // Only recreate when authentication state changes const memoizedAuthHeaders = useMemo(() => { return getAuthHeaders(); - }, [authState.sessionId, authState.apiKey, authState.basicAuth]); + }, [getAuthHeaders]); const handleOnboardingComplete = () => { // After onboarding, reset state to show login @@ -401,7 +389,7 @@ const App: React.FC = () => { } }; - const handleLogout = async () => { + const handleLogout = useCallback(async () => { try { // Stop signer first await signerRef.current?.stopSigner().catch(console.error); @@ -422,7 +410,19 @@ const App: React.FC = () => { }); setSignerData(null); } - }; + }, [getAuthHeaders]); + + // Global handler for authentication/credentials expiry from child components + useEffect(() => { + const onAuthExpired = () => { + // If already unauthenticated, ignore; otherwise trigger logout to show login screen + if (authState.isAuthenticated) { + handleLogout().catch(console.error); + } + }; + window.addEventListener('authExpired', onAuthExpired as EventListener); + return () => window.removeEventListener('authExpired', onAuthExpired as EventListener); + }, [authState.isAuthenticated, handleLogout]); const checkAdmin = async (headers?: Record) => { try { diff --git a/frontend/components/ApiKeys.tsx b/frontend/components/ApiKeys.tsx index 8ac7905..7839f1a 100644 --- a/frontend/components/ApiKeys.tsx +++ b/frontend/components/ApiKeys.tsx @@ -53,6 +53,7 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals // Track copy timeout to avoid leaks if component unmounts before it fires const copyTimeoutRef = useRef | null>(null) + const initialAdminLoadRef = useRef(false) const combinedHeaders = useCallback( (contentType = true) => { @@ -223,9 +224,13 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals const revokedKeys = useMemo(() => keys.filter(key => key.revokedAt), [keys]) useEffect(() => { - if (isAdminUser) { - loadKeys().catch(err => console.error('Failed to load keys:', err)) + if (!isAdminUser) { + initialAdminLoadRef.current = false + return } + if (initialAdminLoadRef.current) return + initialAdminLoadRef.current = true + loadKeys().catch(err => console.error('Failed to load keys:', err)) }, [isAdminUser, loadKeys]) if (headlessMode) { diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index df2ba17..73effdd 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from "react" +import React, { useState, useEffect, useRef, useId, useCallback } from "react" import { Button } from "./ui/button" import { Input } from "./ui/input" import { Tooltip } from "./ui/tooltip" @@ -80,6 +80,10 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav const [isLoadingConfig, setIsLoadingConfig] = useState(true); const [advancedError, setAdvancedError] = useState(undefined); const loadAdvancedSettingsRef = useRef(null); + const clearConfirmButtonRef = useRef(null); + const clearCancelButtonRef = useRef(null); + const clearTriggerButtonRef = useRef(null); + const clearDialogTitleId = useId(); /** * Convert an environment value of unknown type to a string suitable for input fields. @@ -106,7 +110,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } // Function to load advanced settings from env - const loadAdvancedSettings = async () => { + const loadAdvancedSettings = useCallback(async (headlessMode: boolean) => { // Load advanced settings in both headless and database modes if (loadAdvancedSettingsRef.current) { try { loadAdvancedSettingsRef.current.abort() } catch {} @@ -144,7 +148,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav // Only include RELAYS in headless mode (server-wide configuration) // In database mode, relays are managed per-user through the Signer component - if (isHeadlessMode) { + if (headlessMode) { newSettings.RELAYS = coerceEnvValueToString(envVars.RELAYS, '["wss://relay.primal.net"]'); } setAdvancedSettings(newSettings); @@ -162,7 +166,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } finally { setIsLoadingAdvanced(false); } - }; + }, [authHeaders]); const handleRevealAdminSecret = async () => { if (!canRevealAdminSecret) return; @@ -261,7 +265,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } // Load advanced settings in both modes - await loadAdvancedSettings(); + await loadAdvancedSettings(headlessMode); // Store existing relays (if any) if (savedRelays && Array.isArray(savedRelays) && savedRelays.length > 0) { @@ -305,15 +309,15 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav } }; loadExistingData(); - }, []); + }, [authHeaders, loadAdvancedSettings]); // Reload advanced settings when window regains focus or when showAdvanced changes // This ensures relay changes from Signer.tsx are reflected here useEffect(() => { if (!showAdvanced) return; - const handleFocus = () => { loadAdvancedSettings() }; - loadAdvancedSettings(); + const handleFocus = () => { void loadAdvancedSettings(isHeadlessMode) }; + void loadAdvancedSettings(isHeadlessMode); window.addEventListener('focus', handleFocus); return () => { window.removeEventListener('focus', handleFocus); @@ -322,7 +326,41 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav loadAdvancedSettingsRef.current = null } }; - }, [showAdvanced]); + }, [showAdvanced, isHeadlessMode, loadAdvancedSettings]); + + useEffect(() => { + if (!showClearConfirm) return; + const previousFocus = (document.activeElement as HTMLElement | null) ?? clearTriggerButtonRef.current; + const focusPrimary = () => clearConfirmButtonRef.current?.focus(); + const raf = window.requestAnimationFrame(focusPrimary); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setShowClearConfirm(false); + return; + } + if (event.key !== 'Tab') return; + const focusables = [clearCancelButtonRef.current, clearConfirmButtonRef.current].filter(Boolean) as HTMLElement[]; + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => { + window.cancelAnimationFrame(raf); + window.removeEventListener('keydown', handleKeyDown); + (clearTriggerButtonRef.current ?? previousFocus)?.focus(); + }; + }, [showClearConfirm]); const handleNameChange = (value: string) => { setKeysetName(value); @@ -712,6 +750,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav
{hasExistingCredentials && ( diff --git a/package.json b/package.json index 80f107e..d0704c8 100644 --- a/package.json +++ b/package.json @@ -74,11 +74,11 @@ "typescript": "^5.7.3" }, "overrides": { - "glob": "^10.5.0", - "minimatch": "^10.2.3", - "js-yaml": "^4.1.1", - "undici": "^6.23.0", - "ajv": "^8.18.0", - "fast-xml-parser": "^5.3.8" + "glob": "10.5.0", + "minimatch": "10.2.4", + "js-yaml": "4.1.1", + "undici": "6.23.0", + "ajv": "8.18.0", + "fast-xml-parser": "5.4.1" } } diff --git a/packages/umbrel/igloo/Dockerfile b/packages/umbrel/igloo/Dockerfile index 9f3659d..fb6bd0e 100644 --- a/packages/umbrel/igloo/Dockerfile +++ b/packages/umbrel/igloo/Dockerfile @@ -1,5 +1,5 @@ # Umbrel-specific igloo-server image with non-root runtime user -FROM oven/bun:1.1.30 AS build +FROM oven/bun:1.3.10 AS build WORKDIR /app @@ -21,7 +21,7 @@ COPY tsconfig.json ./ RUN bun run build # Production stage with non-root user that matches Umbrel defaults -FROM oven/bun:1.1.30 AS production +FROM oven/bun:1.3.10 AS production ARG IGLOO_USER=igloo ARG IGLOO_UID=1000 diff --git a/src/const.ts b/src/const.ts index edc6387..ed2e69a 100644 --- a/src/const.ts +++ b/src/const.ts @@ -23,8 +23,11 @@ export const RELAYS: string[] = (() => { })(); export const HOST_NAME = process.env['HOST_NAME'] ?? 'localhost' -const parsedHostPort = parseInt(process.env['HOST_PORT'] ?? '8002', 10) -export const HOST_PORT = Number.isNaN(parsedHostPort) ? 8002 : parsedHostPort +const rawHostPort = process.env['HOST_PORT']?.trim() +const parsedHostPort = rawHostPort && /^\d+$/.test(rawHostPort) ? Number(rawHostPort) : NaN +export const HOST_PORT = Number.isInteger(parsedHostPort) && parsedHostPort >= 1 && parsedHostPort <= 65535 + ? parsedHostPort + : 8002 // Raw credential strings for igloo-core functions - treat empty/whitespace as absent export const GROUP_CRED = (() => { diff --git a/src/db/database.ts b/src/db/database.ts index 1071c2e..5c2e8b5 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -11,11 +11,7 @@ const defaultDbDir = path.join(process.cwd(), 'data'); const envPath = process.env.DB_PATH; const isEnvPathFile = !!envPath && ( envPath.endsWith('.db') || - ( - path.extname(envPath) !== '' && - !envPath.endsWith(path.sep) && - path.basename(envPath).includes('.') - ) + (path.extname(envPath) !== '' && !envPath.endsWith(path.sep)) ); const DB_DIR = isEnvPathFile ? path.dirname(envPath as string) : (envPath || defaultDbDir); const DB_FILE = isEnvPathFile ? (envPath as string) : path.join(DB_DIR, 'igloo.db'); diff --git a/src/db/migrator.ts b/src/db/migrator.ts index 987516c..2376e55 100644 --- a/src/db/migrator.ts +++ b/src/db/migrator.ts @@ -1,5 +1,5 @@ import path from 'path' -import { existsSync, readdirSync, readFileSync } from 'fs' +import { existsSync, readdirSync, readFileSync, realpathSync } from 'fs' import db from './database.js' function ensureMigrationsTable() { @@ -26,13 +26,20 @@ export function runMigrations(migrationsDirRel = 'src/db/migrations', opts?: { s path.isAbsolute(migrationsDirRel) ? migrationsDirRel : path.join(process.cwd(), migrationsDirRel) ) + if (!existsSync(dir)) return [] + // Security: Ensure migrations directory is within project boundaries const projectRoot = path.resolve(process.cwd()) - if (!dir.startsWith(projectRoot + path.sep)) { - throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) + try { + const resolvedRealDir = realpathSync(dir) + const resolvedProjectRoot = realpathSync(projectRoot) + if (resolvedRealDir !== resolvedProjectRoot && !resolvedRealDir.startsWith(resolvedProjectRoot + path.sep)) { + throw new Error(`Security: Migration directory must be within project root. Attempted: ${dir}`) + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Security: Failed to validate migration directory path: ${detail}`) } - - if (!existsSync(dir)) return [] const files = readdirSync(dir) .filter(f => f.toLowerCase().endsWith('.sql')) .sort() diff --git a/src/node/manager.ts b/src/node/manager.ts index 32c1397..b545e50 100644 --- a/src/node/manager.ts +++ b/src/node/manager.ts @@ -2185,15 +2185,29 @@ export async function createNodeWithCredentials( const result = await withSimplePoolSubscribeManyLock(async () => { let restoreSubscribeMany: (() => void) | null = null; try { - const poolProto: any = SimplePool?.prototype; - const originalSubscribeMany = poolProto?.subscribeMany; + const poolProtoUnknown: unknown = SimplePool?.prototype; + const poolProto = poolProtoUnknown && typeof poolProtoUnknown === 'object' + ? poolProtoUnknown as { subscribeMany?: unknown } + : null; + const originalSubscribeMany: unknown = poolProto?.subscribeMany; if (poolProto && typeof originalSubscribeMany === 'function') { + const originalSubscribeManyFn = originalSubscribeMany as ( + this: unknown, + relays: unknown, + filters: unknown, + params: unknown + ) => unknown; // Scope filter normalization to this node-creation attempt only. - poolProto.subscribeMany = function normalizedSubscribeMany(this: any, relays: any, filters: any, params: any) { + poolProto.subscribeMany = function normalizedSubscribeMany( + this: unknown, + relays: unknown, + filters: unknown, + params: unknown + ) { const normalizedFilters = Array.isArray(filters) && filters.length === 1 && filters[0] && typeof filters[0] === 'object' && !Array.isArray(filters[0]) ? filters[0] : filters; - return originalSubscribeMany.call(this, relays, normalizedFilters, params); + return originalSubscribeManyFn.call(this, relays, normalizedFilters, params); }; restoreSubscribeMany = () => { poolProto.subscribeMany = originalSubscribeMany; diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 50e951b..29c447c 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -24,6 +24,15 @@ interface RevokeApiKeyRequest { reason?: unknown; } +const KNOWN_ADMIN_PATHS = new Set([ + '/api/admin/whoami', + '/api/admin/users', + '/api/admin/users/delete', + '/api/admin/api-keys', + '/api/admin/api-keys/revoke', + '/api/admin/status', +]); + /** * Convert various input types into a normalized positive integer (number or bigint). * Accepts number, numeric string (e.g. "1", "42"), and bigint (e.g. 1n). @@ -453,15 +462,7 @@ export async function handleAdminRoute( break; } - const knownAdminPaths = new Set([ - '/api/admin/whoami', - '/api/admin/users', - '/api/admin/users/delete', - '/api/admin/api-keys', - '/api/admin/api-keys/revoke', - '/api/admin/status', - ]); - const isKnownPath = knownAdminPaths.has(url.pathname); + const isKnownPath = KNOWN_ADMIN_PATHS.has(url.pathname); return Response.json( { error: isKnownPath ? 'Method not allowed' : 'Not found' }, { status: isKnownPath ? 405 : 404, headers } diff --git a/src/routes/auth.ts b/src/routes/auth.ts index cb801fe..5c6d739 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -1211,7 +1211,12 @@ function getAvailableAuthMethods(): string[] { } // Status endpoint for authentication info -export function getAuthStatus(): object { +export function getAuthStatus(): { + enabled: boolean; + methods: string[]; + rateLimiting: boolean; + sessionTimeout: number; +} { return { enabled: AUTH_CONFIG.ENABLED, methods: getAvailableAuthMethods(), diff --git a/src/routes/index.ts b/src/routes/index.ts index 264a304..3facd24 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -132,9 +132,10 @@ export async function handleRequest( if (AUTH_CONFIG.ENABLED && process.env.NODE_ENV === 'production') { const authResult = await authenticate(req); if (!authResult.authenticated) { + const authStatus = getAuthStatus(); return Response.json({ error: 'Authentication required for API documentation in production', - authMethods: getAuthStatus() + authMethods: authStatus.methods }, { status: 401, headers @@ -228,9 +229,10 @@ export async function handleRequest( if (!authResult.authenticated) { // Don't set WWW-Authenticate header to avoid browser's native auth dialog // The frontend will handle authentication through its own UI + const authStatus = getAuthStatus(); return Response.json({ error: authResult.error || 'Authentication required', - authMethods: getAuthStatus() + authMethods: authStatus.methods }, { status: 401, headers diff --git a/src/routes/nip04.ts b/src/routes/nip04.ts index c9ab656..43c586d 100644 --- a/src/routes/nip04.ts +++ b/src/routes/nip04.ts @@ -88,8 +88,13 @@ export async function handleNip04Route(req: Request, url: URL, context: RouteCon // Separate bucket for crypto operations const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const resetAt = typeof rate.resetAt === 'number' && Number.isFinite(rate.resetAt) ? rate.resetAt : null + const retryAfterFromReset = resetAt !== null + ? Math.max(0, Math.ceil((resetAt - Date.now()) / 1000)) + : null const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '900', 10) - const retryAfter = Number.isFinite(retryAfterWindow) ? Math.ceil(retryAfterWindow) : 900 + const retryAfterFallback = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900 + const retryAfter = retryAfterFromReset !== null ? retryAfterFromReset : retryAfterFallback return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, 'Retry-After': retryAfter.toString() } diff --git a/src/routes/nip44.ts b/src/routes/nip44.ts index 9c0e07c..885f9bc 100644 --- a/src/routes/nip44.ts +++ b/src/routes/nip44.ts @@ -38,9 +38,11 @@ export async function handleNip44Route(req: Request, url: URL, context: RouteCon // Use a dedicated bucket separate from signing traffic. const rate = await checkRateLimit(req, 'crypto', { clientIp: context.clientIp }); if (!rate.allowed) { + const retryAfterWindow = Number.parseInt(process.env.RATE_LIMIT_WINDOW || '', 10); + const retryAfterSeconds = Number.isFinite(retryAfterWindow) && retryAfterWindow > 0 ? retryAfterWindow : 900; return Response.json({ error: 'Rate limit exceeded. Try again later.' }, { status: 429, - headers: { ...headers, 'Retry-After': Math.ceil(parseInt(process.env.RATE_LIMIT_WINDOW || '900')).toString() } + headers: { ...headers, 'Retry-After': Math.ceil(retryAfterSeconds).toString() } }); } diff --git a/src/routes/onboarding.ts b/src/routes/onboarding.ts index 85632f8..ed4879a 100644 --- a/src/routes/onboarding.ts +++ b/src/routes/onboarding.ts @@ -268,10 +268,10 @@ const UNIFORM_AUTH_ERROR = { error: 'Authentication failed' }; // - Uppercase letter // - Lowercase letter // - Digit -// - Special character (must include at least one of @$!%*?&) +// - Special character (any non-alphanumeric, excluding whitespace) // Note: Length validation is handled by VALIDATION.MIN_PASSWORD_LENGTH and VALIDATION.MAX_PASSWORD_LENGTH. // Whitespace is allowed and preserved by policy. -const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&]).*$/; +const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).*$/; /** * Validates the admin secret in a timing-safe manner diff --git a/src/routes/peers.ts b/src/routes/peers.ts index ae63a02..a84e050 100644 --- a/src/routes/peers.ts +++ b/src/routes/peers.ts @@ -107,15 +107,16 @@ async function persistUserPeerPolicies( })); const sanitizedPolicies = sanitizePeerPolicyEntries(rawPolicies) as StoredPeerPolicy[]; const hasPolicies = sanitizedPolicies.length > 0; + const fallbackPolicies = hasPolicies ? sanitizedPolicies : null; if (HEADLESS) { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + await saveFallbackPeerPolicies(fallbackPolicies); return; } const userId = resolveDatabaseUserId(auth); if (userId === null) { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + await saveFallbackPeerPolicies(fallbackPolicies); return; } @@ -123,21 +124,24 @@ async function persistUserPeerPolicies( const { updateUserPeerPolicies } = await import('../db/database.js'); if (!context.node || summaries.length === 0) { - await updateUserPeerPolicies(userId, null); + const success = updateUserPeerPolicies(userId, null); + if (!success) { + console.warn('Failed to clear peer policies for user', userId); + throw new Error('Failed to persist peer policies'); + } await saveFallbackPeerPolicies(null); return; } - const success = await updateUserPeerPolicies(userId, sanitizedPolicies); + const success = updateUserPeerPolicies(userId, sanitizedPolicies); if (!success) { console.warn('Failed to persist peer policies for user', userId); - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); - } else { - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + throw new Error('Failed to persist peer policies'); } + await saveFallbackPeerPolicies(fallbackPolicies); } catch (error) { console.error('Failed to persist peer policies:', error); - await saveFallbackPeerPolicies(hasPolicies ? sanitizedPolicies : null); + throw error; } } @@ -368,8 +372,8 @@ export async function handlePeersRoute(req: Request, url: URL, context: RouteCon }, { status: 400, headers }); } } catch (error) { - const message = error instanceof Error ? error.message : 'Malformed credentials'; - return Response.json({ error: 'Malformed credentials', warnings: [message] }, { status: 400, headers }); + console.error('Failed to extract self pubkey from credentials:', error); + return Response.json({ error: 'Malformed credentials', warnings: ['Invalid credentials format'] }, { status: 400, headers }); } } break; @@ -643,10 +647,10 @@ async function handlePingAllPeers(context: RouteContext, headers: Record 0 ? retryAfterWindow : 900; + const retryAfterSeconds = retryAfterFromReset !== null ? retryAfterFromReset : retryAfterFallback; return Response.json({ code: 'RATE_LIMITED', error: 'Rate limit exceeded. Try again later.' }, { status: 429, headers: { ...headers, 'Retry-After': retryAfterSeconds.toString() } diff --git a/src/routes/update.ts b/src/routes/update.ts index 90ff43c..ba743b3 100644 --- a/src/routes/update.ts +++ b/src/routes/update.ts @@ -40,7 +40,7 @@ interface UpdateResponse { const UPDATE_CHECK_TIMEOUT_MS = parseInt(process.env['UPDATE_CHECK_TIMEOUT_MS'] ?? '5000', 10) || 5000; const UPDATE_CHECK_TTL_MS = parseInt(process.env['UPDATE_CHECK_TTL_MS'] ?? '21600000', 10) || 21_600_000; // 6 hours const UPDATE_CHECK_FAILURE_TTL_MS = parseInt(process.env['UPDATE_CHECK_FAILURE_TTL_MS'] ?? '900000', 10) || 900_000; // 15 minutes -const ALLOW_PRERELEASE_UPDATES = false; +const ALLOW_PRERELEASE_UPDATES = parseBoolean(process.env['ALLOW_PRERELEASE_UPDATES']); const GITHUB_OWNER = 'FROSTR-ORG'; const GITHUB_REPO = 'igloo-server'; diff --git a/src/utils/rate-limiter.ts b/src/utils/rate-limiter.ts index 9d74261..5e1b538 100644 --- a/src/utils/rate-limiter.ts +++ b/src/utils/rate-limiter.ts @@ -251,7 +251,8 @@ export class PersistentRateLimiter { // Clear all buckets for this identifier const keys = Array.from(this.fallbackStore.keys()); for (const key of keys) { - const keyIdentifier = key.slice(key.lastIndexOf(':') + 1); + const separatorIndex = key.indexOf(':'); + const keyIdentifier = separatorIndex === -1 ? key : key.slice(separatorIndex + 1); if (keyIdentifier === identifier) { this.fallbackStore.delete(key); } diff --git a/tests/routes/helpers/script-runner.ts b/tests/routes/helpers/script-runner.ts index 1c38aaf..7eeadbe 100644 --- a/tests/routes/helpers/script-runner.ts +++ b/tests/routes/helpers/script-runner.ts @@ -43,7 +43,7 @@ function toSafePreview(raw: string, maxChars = ERROR_PREVIEW_MAX_CHARS): string if (!compact) return '(empty)'; const redacted = compact .replace( - /(["']?(?:admin_secret|session_secret|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, + /(["']?(?:admin_secret|session_secret|derived[_-]?key|encryption[_-]?key|password|api[_-]?key|token)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^,"'\s}]+)/ig, '$1' ) .replace(/(bearer\s+)[a-z0-9._-]+/ig, '$1'); From 269376f78c29e8c5f192bf1af91f5aa8fd6e7527 Mon Sep 17 00:00:00 2001 From: Austin Kelsay Date: Sat, 28 Feb 2026 12:28:20 -0600 Subject: [PATCH 50/69] fix: apply requested API and UI hardening updates --- .github/workflows/ci.yml | 12 +- .github/workflows/release.yml | 60 +- .gitignore | 2 + compose.yml | 5 +- docs/CONFIG.md | 1 + docs/DEPLOY.md | 4 +- docs/RELEASE.md | 10 +- docs/SECURITY.md | 2 +- docs/openapi/openapi.json | 598 ++++++++++++++++++ docs/openapi/openapi.yaml | 513 ++++++++++++++- env.example | 11 +- frontend/App.tsx | 4 +- frontend/components/ApiKeys.tsx | 2 + frontend/components/Configure.tsx | 6 +- frontend/components/Login.tsx | 14 +- frontend/components/NIP46.tsx | 56 +- frontend/components/Onboarding.tsx | 1 - frontend/components/Recover.tsx | 81 ++- frontend/components/Signer.tsx | 75 ++- frontend/components/nip46/QRScanner.tsx | 23 +- frontend/components/nip46/RelaySettings.tsx | 16 +- frontend/components/ui/button.tsx | 7 +- frontend/components/ui/collapsible.tsx | 2 +- frontend/components/ui/icon-button.tsx | 2 +- frontend/components/ui/modal.tsx | 52 +- frontend/components/ui/peer-list.tsx | 37 +- frontend/components/ui/relay-input.tsx | 6 +- frontend/styles.css | 5 - frontend/tsconfig.json | 2 +- frontend/types/nostr-connect.d.ts | 8 +- llm/implementation/auth-implementation.md | 2 +- .../node-lifecycle-implementation.md | 3 +- llm/workflows/RELEASE_PROCESS.md | 20 +- package.json | 2 +- scripts/api/README.md | 1 + scripts/api/test-get-endpoints.ts | 3 +- scripts/api/test-ws-events.ts | 5 +- scripts/fetch-swagger-ui.mjs | 2 +- scripts/patch-zod-compat.mjs | 17 +- scripts/release.sh | 17 +- scripts/start-headless.js | 26 + src/class/relay.test.ts | 5 +- src/class/relay.ts | 4 +- src/const.ts | 1 - src/db/database.ts | 50 +- .../20250922_0007_add_nip46_relays.sql | 6 +- src/db/migrator.ts | 12 +- src/db/nip46.ts | 83 ++- src/nip46/index.ts | 16 + src/nip46/service.ts | 34 +- src/node/manager.ts | 165 ++--- src/routes/auth-factory.ts | 2 +- src/routes/auth.ts | 16 +- src/routes/docs.ts | 14 +- src/routes/env.ts | 174 ++--- src/routes/index.ts | 18 + src/routes/nip44.ts | 10 +- src/routes/nip46.ts | 167 ++++- src/routes/node-manager.ts | 7 + src/routes/onboarding.ts | 9 +- src/routes/peers.ts | 266 ++++++-- src/routes/sign.ts | 32 +- src/routes/user.ts | 8 +- src/routes/utils.ts | 37 +- src/server.ts | 15 +- src/types/global.d.ts | 6 + static/docs/swagger-ui-bundle.js | 3 - static/index.html | 4 +- tests/routes/admin.api-keys.negatives.spec.ts | 46 +- tests/routes/admin.whoami.session.spec.ts | 13 +- tests/routes/env.db-mode.spec.ts | 98 +-- tests/routes/helpers/script-runner.ts | 2 +- tests/routes/nip46.spec.ts | 44 ++ tests/routes/protected.api.spec.ts | 4 +- tests/routes/user-peers.spec.ts | 326 ++++++++++ 75 files changed, 2828 insertions(+), 584 deletions(-) create mode 100755 scripts/start-headless.js delete mode 100644 static/docs/swagger-ui-bundle.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7d416e..c10d3fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,13 +142,13 @@ jobs: - name: Test Docker image run: | + set -euo pipefail + trap 'docker stop test-container >/dev/null 2>&1 || true; docker rm test-container >/dev/null 2>&1 || true' EXIT docker run -d --name test-container -p 8002:8002 \ -e AUTO_ADMIN_SECRET=true \ igloo-server:test sleep 10 - curl -f http://localhost:8002/api/status || exit 1 - docker stop test-container - docker rm test-container + curl -f http://localhost:8002/api/status - name: Build Umbrel Docker image uses: docker/build-push-action@v5 @@ -162,12 +162,12 @@ jobs: - name: Test Umbrel Docker image run: | + set -euo pipefail + trap 'docker stop test-umbrel >/dev/null 2>&1 || true; docker rm test-umbrel >/dev/null 2>&1 || true' EXIT docker run -d --name test-umbrel -p 8003:8002 \ -e ADMIN_SECRET=ci-admin-secret \ -e ALLOWED_ORIGINS=http://localhost:8003 \ -e TRUST_PROXY=true \ igloo-server-umbrel:test sleep 10 - curl -f http://localhost:8003/api/status || exit 1 - docker stop test-umbrel - docker rm test-umbrel + curl -f http://localhost:8003/api/status diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1317cfe..9096905 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,25 +87,45 @@ jobs: echo "# CHANGELOG" > CHANGELOG.md echo "" >> CHANGELOG.md fi - - # Add new version entry + + VERSION="${{ steps.new_version.outputs.version_number }}" DATE=$(date +%Y-%m-%d) - sed -i "3i\\## [${{ steps.new_version.outputs.version_number }}] - $DATE\\n" CHANGELOG.md - - # Add commit messages since last tag + if ! grep -Fq "## [${VERSION}]" CHANGELOG.md; then + awk -v version="$VERSION" -v date="$DATE" ' + NR == 1 { print; print ""; print "## [" version "] - " date; print ""; next } + { print } + ' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + fi + + temp_changelog="$(mktemp)" + + # Add commit messages since last tag (safe for special chars in subjects) LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") if [ -n "$LAST_TAG" ]; then - echo "### Changes since $LAST_TAG:" >> temp_changelog.md - git log --pretty=format:"- %s" $LAST_TAG..HEAD >> temp_changelog.md + printf '### Changes since %s:\n' "$LAST_TAG" > "$temp_changelog" + git log --pretty=format:'%s%x00' "$LAST_TAG..HEAD" \ + | tr '\0' '\n' \ + | awk 'NF { print "- " $0 }' >> "$temp_changelog" else - echo "### Changes:" >> temp_changelog.md - git log --pretty=format:"- %s" -n 10 >> temp_changelog.md + printf '### Changes:\n' > "$temp_changelog" + git log --pretty=format:'%s%x00' -n 10 \ + | tr '\0' '\n' \ + | awk 'NF { print "- " $0 }' >> "$temp_changelog" fi - echo "" >> temp_changelog.md - - # Insert changes into changelog - sed -i "/## \[${{ steps.new_version.outputs.version_number }}\]/r temp_changelog.md" CHANGELOG.md - rm temp_changelog.md + printf '\n' >> "$temp_changelog" + + # Insert changes into changelog after the current version heading + awk -v target="## [${VERSION}]" -v insert_file="$temp_changelog" ' + { print } + $0 == target && !inserted { + while ((getline line < insert_file) > 0) print line + close(insert_file) + inserted = 1 + } + ' CHANGELOG.md > CHANGELOG.md.tmp + mv CHANGELOG.md.tmp CHANGELOG.md + rm -f "$temp_changelog" - name: Create release archive run: | @@ -133,8 +153,14 @@ jobs: # Create git tag for release (works with branch protection) # Note: Version changes are not committed back to main due to branch protection # The release archives will contain the correct versions - git tag ${{ steps.new_version.outputs.new_version }} - git push origin ${{ steps.new_version.outputs.new_version }} + TAG="${{ steps.new_version.outputs.new_version }}" + git fetch --tags origin + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists, skipping tag creation" + else + git tag "${TAG}" + git push origin "${TAG}" + fi - name: Create GitHub release and upload assets uses: softprops/action-gh-release@v2 @@ -172,6 +198,8 @@ jobs: docker: runs-on: ubuntu-latest needs: release + permissions: + packages: write steps: - name: Checkout code diff --git a/.gitignore b/.gitignore index eda8834..ee63590 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,8 @@ dist static/app.js static/app.css static/styles.css +static/docs/swagger-ui-bundle.js +static/docs/swagger-ui-bundle.js.map static/qr-scanner-worker.min.js # VSCode diff --git a/compose.yml b/compose.yml index 96b5bb0..ebac7a3 100644 --- a/compose.yml +++ b/compose.yml @@ -15,8 +15,8 @@ services: - HOST_NAME=0.0.0.0 - HOST_PORT=8002 - NODE_ENV=production - # Explicit DB path inside the container; lives under /app/data - - DB_PATH=/app/data/igloo.db + # DB directory inside the container; database file defaults to /app/data/igloo.db + - DB_PATH=/app/data container_name: igloo-server hostname: igloo-server @@ -49,4 +49,3 @@ services: networks: infranet: driver: bridge - diff --git a/docs/CONFIG.md b/docs/CONFIG.md index f30b6c6..d9e6d04 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -90,6 +90,7 @@ Timeouts: WebSocket abuse controls: - `RATE_LIMIT_WS_UPGRADE_WINDOW`, `RATE_LIMIT_WS_UPGRADE_MAX` - `WS_MAX_CONNECTIONS_PER_IP`, `WS_MSG_RATE`, `WS_MSG_BURST` +- `ALLOW_QUERY_CREDENTIALS` (default `true` for legacy `/api/events?apiKey=...` / `sessionId` compatibility; set `false` to disable query-param auth on upgrades) Recovery throttling: - `RATE_LIMIT_RECOVERY_WINDOW`, `RATE_LIMIT_RECOVERY_MAX` diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ba2391c..611f4bb 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -32,14 +32,14 @@ sudo chmod +x /usr/local/bin/docker-compose ``` 2) Pull and run (pin a release tag for reproducibility, e.g., `1.1.1` or `umbrel-1.1.1`): ```bash -docker pull ghcr.io/frostr-org/igloo-server:latest +docker pull ghcr.io/frostr-org/igloo-server:1.1.1 docker run -d --name igloo-server -p 8002:8002 \ -v $PWD/data:/app/data \ -e ADMIN_SECRET=$(openssl rand -hex 32) \ -e AUTH_ENABLED=true \ -e TRUST_PROXY=true \ -e ALLOWED_ORIGINS=https://yourdomain.com \ - ghcr.io/frostr-org/igloo-server:latest + ghcr.io/frostr-org/igloo-server:1.1.1 ``` 3) Docker Compose option (create `docker-compose.yml`): ```yaml diff --git a/docs/RELEASE.md b/docs/RELEASE.md index ab3ef2c..400a61e 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -37,10 +37,10 @@ bun run docs:validate git checkout -b release/prepare-v1.1.1 git push origin release/prepare-v1.1.1 ``` -Create PR: `release/prepare-v1.1.1` → `master` +Create PR: `release/prepare-v1.1.1` → `main` ### 3. Merge & Release -- Merge PR to `master` +- Merge PR to `main` - GitHub Actions automatically: - Bumps version in `package.json` - Updates `CHANGELOG.md` @@ -50,7 +50,7 @@ Create PR: `release/prepare-v1.1.1` → `master` ### 4. Verify Release - ✅ Check [GitHub Releases](https://github.com/FROSTR-ORG/igloo-server/releases) - ✅ Test Docker image: `docker pull ghcr.io/frostr-org/igloo-server:latest` -- ✅ Sync dev: `git checkout dev && git merge master && git push origin dev` +- ✅ Sync dev: `git checkout dev && git merge main && git push origin dev` ## 🔄 Version Bumping Logic @@ -66,11 +66,11 @@ GitHub Actions automatically detects version type from commit messages: For critical fixes: ```bash -git checkout master +git checkout main git checkout -b hotfix/critical-fix # Make fix and commit git push origin hotfix/critical-fix -# Create PR to master +# Create PR to main ``` ## 📦 Release Artifacts diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 43a2abe..95d80e5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -647,7 +647,7 @@ bun run start # 2. Test with API key authentication AUTH_ENABLED=true API_KEY= # Replace with test API key -curl -H "X-API-Key: test-api-key-12345" http://localhost:8002/api/status +curl -H "X-API-Key: " http://localhost:8002/api/status # 3. Test with basic authentication AUTH_ENABLED=true diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 2653b2c..61d362d 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -85,6 +85,14 @@ { "name": "Event Log", "description": "Persisted UI event log endpoints (database mode only)" + }, + { + "name": "Crypto", + "description": "Cryptographic operations and key management" + }, + { + "name": "NIP‑46", + "description": "NIP‑46 protocol and signing interactions" } ], "paths": { @@ -2098,6 +2106,426 @@ } } }, + "/api/nip46/requests": { + "get": { + "operationId": "listNip46Requests", + "summary": "List persisted NIP‑46 requests", + "tags": [ + "NIP‑46" + ], + "parameters": [ + { + "in": "query", + "name": "status", + "schema": { + "type": "string" + }, + "description": "Comma-separated request statuses (pending, approved, denied, completed, failed, expired)" + }, + { + "in": "query", + "name": "limit", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "description": "Number of requests to return (default 100)" + }, + { + "in": "query", + "name": "beforeCreatedAt", + "schema": { + "type": "string" + }, + "description": "Cursor created_at value for pagination (must be paired with beforeId)" + }, + { + "in": "query", + "name": "beforeId", + "schema": { + "type": "string" + }, + "description": "Cursor id value for pagination (must be paired with beforeCreatedAt)" + } + ], + "responses": { + "200": { + "description": "Requests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "requests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Nip46Request" + } + }, + "nextCursor": { + "oneOf": [ + { + "type": "object", + "properties": { + "createdAt": { + "type": "string" + }, + "id": { + "type": "string" + } + } + }, + { + "type": "null" + } + ] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "updateNip46Request", + "summary": "Update a NIP‑46 request status", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RequestActionInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated request", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "request": { + "$ref": "#/components/schemas/Nip46Request" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + }, + "delete": { + "operationId": "deleteNip46Request", + "summary": "Delete a persisted NIP‑46 request", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RequestDeleteInput" + } + } + } + }, + "responses": { + "200": { + "description": "Request deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "description": "Forbidden", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "404": { + "$ref": "#/components/responses/NotFound" + } + } + } + }, + "/api/nip46/connect": { + "post": { + "operationId": "createNip46Connect", + "summary": "Process a nostrconnect invite string", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46ConnectInput" + } + } + } + }, + "responses": { + "200": { + "description": "Session created/updated from invite", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46ConnectResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/nip46/relays": { + "get": { + "operationId": "listNip46Relays", + "summary": "Get NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "responses": { + "200": { + "description": "Relay list", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "put": { + "operationId": "updateNip46Relays", + "summary": "Replace NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RelaysInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated relays", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "post": { + "operationId": "mergeNip46Relays", + "summary": "Merge relays into NIP‑46 relay pool", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46RelaysInput" + } + } + } + }, + "responses": { + "200": { + "description": "Updated relays", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "relays": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, + "/api/nip46/transport": { + "get": { + "operationId": "getNip46Transport", + "summary": "Get NIP‑46 transport key", + "tags": [ + "NIP‑46" + ], + "responses": { + "200": { + "description": "Transport key", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46Transport" + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + }, + "put": { + "operationId": "updateNip46Transport", + "summary": "Set or rotate NIP‑46 transport key", + "tags": [ + "NIP‑46" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Nip46Transport" + } + } + } + }, + "responses": { + "200": { + "description": "Updated transport key", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ok": { + "type": "boolean" + }, + "transport_sk": { + "type": "string" + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + } + } + } + }, "/api/admin/api-keys": { "get": { "operationId": "listAdminApiKeys", @@ -3671,6 +4099,176 @@ } } }, + "Nip46Request": { + "type": "object", + "description": "Persisted NIP‑46 request payload and lifecycle status", + "properties": { + "id": { + "type": "string" + }, + "user_id": { + "oneOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + }, + "session_pubkey": { + "type": "string" + }, + "method": { + "type": "string" + }, + "params": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "approved", + "denied", + "completed", + "failed", + "expired" + ] + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + }, + "created_at": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "expires_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "user_id", + "session_pubkey", + "method", + "params", + "status", + "created_at", + "updated_at" + ] + }, + "Nip46RequestActionInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Persisted request identifier" + }, + "action": { + "type": "string", + "enum": [ + "approve", + "deny", + "fail", + "complete" + ] + }, + "policy": { + "$ref": "#/components/schemas/Nip46Policy" + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "error": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "id", + "action" + ] + }, + "Nip46RequestDeleteInput": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "Nip46ConnectInput": { + "type": "object", + "properties": { + "uri": { + "type": "string", + "description": "nostrconnect:// invite URI" + } + }, + "required": [ + "uri" + ] + }, + "Nip46ConnectResponse": { + "type": "object", + "properties": { + "session": { + "$ref": "#/components/schemas/Nip46Session" + } + } + }, + "Nip46RelaysInput": { + "type": "object", + "properties": { + "relays": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "required": [ + "relays" + ] + }, + "Nip46Transport": { + "type": "object", + "properties": { + "transport_sk": { + "type": "string", + "description": "32-byte transport secret key encoded as hex" + } + }, + "required": [ + "transport_sk" + ] + }, "AdminApiKey": { "type": "object", "description": "Metadata about an API key managed in database mode", diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index c2eea82..1b69498 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -54,12 +54,7 @@ servers: description: Local development server (HTTP for local development only) x-internal-only: true -security: - - apiKeyAuth: [] - - bearerAuth: [] - - basicAuth: [] - - sessionCookie: [] - - sessionHeader: [] +security: [] paths: /api/status: @@ -132,6 +127,12 @@ paths: - The stream emits JSON objects shaped like `{ type, message, data?, timestamp, id }`. tags: - Events + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: | @@ -187,6 +188,12 @@ paths: - Use `types` (comma-separated) to filter by event type. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: limit in: query @@ -234,6 +241,12 @@ paths: The UI typically loads this lazily when a log entry is expanded. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: hash in: path @@ -269,6 +282,12 @@ paths: - `types`: comma-separated list of event types to include. tags: - Event Log + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - name: sinceSeq in: query @@ -422,6 +441,12 @@ paths: description: Retrieve current environment configuration (whitelisted variables only) tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Environment variables retrieved successfully @@ -445,6 +470,12 @@ paths: description: Update environment configuration (whitelisted variables only) tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -495,6 +526,12 @@ paths: description: Remove specified environment variables tags: - Configuration + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -547,6 +584,12 @@ paths: description: Return the signing group public key and quorum information tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Group metadata retrieved successfully @@ -584,6 +627,12 @@ paths: description: Get all peers from the group credential with their current status tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Peers retrieved successfully @@ -602,12 +651,12 @@ paths: type: integer example: peers: - - pubkey: "02abcd1234..." + - pubkey: "021111111111111111111111111111111111111111111111111111111111111111" online: true lastSeen: "2025-01-20T12:00:00.000Z" latency: 150 lastPingAttempt: "2025-01-20T11:59:00.000Z" - - pubkey: "03efgh5678..." + - pubkey: "032222222222222222222222222222222222222222222222222222222222222222" online: false lastSeen: null latency: null @@ -632,6 +681,12 @@ paths: description: Get the public key of this node from the share credential tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Self public key retrieved successfully @@ -668,6 +723,12 @@ paths: description: Ping specific peer or all peers to check connectivity tags: - Peers + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -755,6 +816,12 @@ paths: description: Use threshold shares to recover the original secret key tags: - Key Recovery + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -859,6 +926,12 @@ paths: description: Validate group or share credentials without performing recovery tags: - Key Recovery + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -940,6 +1013,12 @@ paths: description: Retrieve currently stored share information when running in headless mode tags: - Share Management + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: Shares retrieved successfully @@ -981,6 +1060,12 @@ paths: description: Save share and group credentials when running in headless mode tags: - Share Management + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -1222,6 +1307,12 @@ paths: operationId: listNip46Sessions summary: List NIP‑46 sessions tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - in: query name: history @@ -1247,6 +1338,12 @@ paths: operationId: upsertNip46Session summary: Create or update a NIP‑46 session tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] requestBody: required: true content: @@ -1273,6 +1370,12 @@ paths: operationId: updateNip46Policy summary: Update session policy tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' requestBody: @@ -1290,6 +1393,12 @@ paths: operationId: updateNip46Status summary: Update session status (revoked deletes) tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' requestBody: @@ -1314,6 +1423,12 @@ paths: operationId: deleteNip46Session summary: Delete a NIP‑46 session tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] parameters: - $ref: '#/components/parameters/PubkeyParam' responses: @@ -1335,6 +1450,12 @@ paths: operationId: nip46History summary: Compact NIP‑46 history summary tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] responses: '200': description: History @@ -1350,6 +1471,289 @@ paths: '401': $ref: '#/components/responses/Unauthorized' + /api/nip46/requests: + get: + operationId: listNip46Requests + summary: List persisted NIP‑46 requests + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + parameters: + - in: query + name: status + schema: + type: string + description: Comma-separated request statuses (pending, approved, denied, completed, failed, expired) + - in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + description: Number of requests to return (default 100) + - in: query + name: beforeCreatedAt + schema: + type: string + description: Cursor created_at value for pagination (must be paired with beforeId) + - in: query + name: beforeId + schema: + type: string + description: Cursor id value for pagination (must be paired with beforeCreatedAt) + responses: + '200': + description: Requests + content: + application/json: + schema: + type: object + properties: + requests: + type: array + items: + $ref: '#/components/schemas/Nip46Request' + nextCursor: + oneOf: + - type: object + properties: + createdAt: { type: string } + id: { type: string } + - type: 'null' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: updateNip46Request + summary: Update a NIP‑46 request status + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RequestActionInput' + responses: + '200': + description: Updated request + content: + application/json: + schema: + type: object + properties: + request: + $ref: '#/components/schemas/Nip46Request' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + delete: + operationId: deleteNip46Request + summary: Delete a persisted NIP‑46 request + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RequestDeleteInput' + responses: + '200': + description: Request deleted + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': + description: Forbidden + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '404': { $ref: '#/components/responses/NotFound' } + + /api/nip46/connect: + post: + operationId: createNip46Connect + summary: Process a nostrconnect invite string + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46ConnectInput' + responses: + '200': + description: Session created/updated from invite + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46ConnectResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '503': { $ref: '#/components/responses/ServiceUnavailable' } + + /api/nip46/relays: + get: + operationId: listNip46Relays + summary: Get NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + responses: + '200': + description: Relay list + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: updateNip46Relays + summary: Replace NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RelaysInput' + responses: + '200': + description: Updated relays + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + operationId: mergeNip46Relays + summary: Merge relays into NIP‑46 relay pool + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46RelaysInput' + responses: + '200': + description: Updated relays + content: + application/json: + schema: + type: object + properties: + relays: + type: array + items: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/nip46/transport: + get: + operationId: getNip46Transport + summary: Get NIP‑46 transport key + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + responses: + '200': + description: Transport key + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46Transport' + '401': { $ref: '#/components/responses/Unauthorized' } + put: + operationId: updateNip46Transport + summary: Set or rotate NIP‑46 transport key + tags: [NIP‑46] + security: + - apiKeyAuth: [] + - bearerAuth: [] + - basicAuth: [] + - sessionCookie: [] + - sessionHeader: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Nip46Transport' + responses: + '200': + description: Updated transport key + content: + application/json: + schema: + type: object + properties: + ok: { type: boolean } + transport_sk: { type: string } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + /api/admin/api-keys: get: operationId: listAdminApiKeys @@ -1800,6 +2204,8 @@ components: description: | API key authentication via X-API-Key header. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory in production environments - API keys must be transmitted over encrypted connections only @@ -1814,6 +2220,8 @@ components: description: | Bearer token authentication (alternative to X-API-Key header). + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory in production environments - Bearer tokens must be transmitted over encrypted connections only @@ -1828,6 +2236,8 @@ components: description: | HTTP Basic Authentication using username and password. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is absolutely critical - Basic Auth transmits credentials in Base64 encoding (not encryption) - Never use Basic Auth over HTTP in production as credentials are easily intercepted @@ -1843,6 +2253,8 @@ components: description: | Session-based authentication via the `X-Session-ID` header. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory to protect session identifiers from interception - Treat the header value like a credential and avoid logging it @@ -1858,6 +2270,8 @@ components: description: | Session-based authentication via HttpOnly cookie. + This scheme is enforced only when `AUTH_ENABLED=true`. When `AUTH_ENABLED=false`, server auth checks are bypassed. + **Security Requirements:** - HTTPS is mandatory so cookies are only sent over encrypted channels - Cookies should be issued with Secure, HttpOnly, and SameSite attributes where appropriate @@ -2409,6 +2823,85 @@ components: type: array items: { type: string } + Nip46Request: + type: object + description: Persisted NIP‑46 request payload and lifecycle status + properties: + id: { type: string } + user_id: + oneOf: + - type: integer + - type: string + session_pubkey: { type: string } + method: { type: string } + params: { type: string } + status: + type: string + enum: [pending, approved, denied, completed, failed, expired] + result: + type: [string, 'null'] + error: + type: [string, 'null'] + created_at: { type: string } + updated_at: { type: string } + expires_at: + type: [string, 'null'] + required: [id, user_id, session_pubkey, method, params, status, created_at, updated_at] + + Nip46RequestActionInput: + type: object + properties: + id: + type: string + description: Persisted request identifier + action: + type: string + enum: [approve, deny, fail, complete] + policy: + $ref: '#/components/schemas/Nip46Policy' + result: + type: [string, 'null'] + error: + type: [string, 'null'] + required: [id, action] + + Nip46RequestDeleteInput: + type: object + properties: + id: + type: string + required: [id] + + Nip46ConnectInput: + type: object + properties: + uri: + type: string + description: nostrconnect:// invite URI + required: [uri] + + Nip46ConnectResponse: + type: object + properties: + session: + $ref: '#/components/schemas/Nip46Session' + + Nip46RelaysInput: + type: object + properties: + relays: + type: ['array', 'null'] + items: { type: string } + required: [relays] + + Nip46Transport: + type: object + properties: + transport_sk: + type: string + description: 32-byte transport secret key encoded as hex + required: [transport_sk] + AdminApiKey: type: object description: Metadata about an API key managed in database mode @@ -2745,3 +3238,7 @@ tags: description: First-run onboarding and admin validation (database mode) - name: Event Log description: Persisted UI event log endpoints (database mode only) + - name: Crypto + description: Cryptographic operations and key management + - name: NIP‑46 + description: NIP‑46 protocol and signing interactions diff --git a/env.example b/env.example index 9eff174..83e02a1 100644 --- a/env.example +++ b/env.example @@ -151,6 +151,11 @@ NIP46_SESSION_RATE_LIMIT_MAX=120 # WS_MAX_CONNECTIONS_PER_IP=5 # WS_MSG_RATE=20 # WS_MSG_BURST=40 +# +# Legacy WebSocket auth compatibility for `/api/events`: +# - true (default): allow `?apiKey=` / `?sessionId=` query params on upgrades. +# - false: require headers or `Sec-WebSocket-Protocol` auth hints instead. +# ALLOW_QUERY_CREDENTIALS=true # ============================================================================= # RECOVERY RATE LIMITS (ADVANCED) @@ -252,7 +257,7 @@ NODE_ENV=development # PERSONAL (Home server - medium security) # AUTH_ENABLED=true # API_KEY=personal-server-key-2024 -# SESSION_SECRET=4f7c1e9a2b3d4c5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6 +# SESSION_SECRET= # SESSION_TIMEOUT=7200 # RATE_LIMIT_MAX=50 @@ -261,7 +266,7 @@ NODE_ENV=development # BASIC_AUTH_USER=teamadmin # BASIC_AUTH_PASS=SecureTeamPassword123! # API_KEY=team-automation-key-64chars -# SESSION_SECRET=9a8b7c6d5e4f3021a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0 +# SESSION_SECRET= # SESSION_TIMEOUT=3600 # RATE_LIMIT_MAX=100 @@ -271,7 +276,7 @@ NODE_ENV=development # BASIC_AUTH_USER=prodadmin # BASIC_AUTH_PASS=VerySecurePassword456! # API_KEY=prod-api-key-with-64-random-chars-abcdef123456789 -# SESSION_SECRET=0123456789abcdef0123456789abcdefabcdef0123456789abcdef0123456789 +# SESSION_SECRET= # SESSION_TIMEOUT=1800 # RATE_LIMIT_ENABLED=true # RATE_LIMIT_WINDOW=300 diff --git a/frontend/App.tsx b/frontend/App.tsx index 0ad15c7..782426e 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -311,7 +311,9 @@ const App: React.FC = () => { setSignerData(prev => prev ?? { share: '', groupCredential: '', name: 'Server credentials' }); } } - } catch {} + } catch (error) { + console.error('Failed to fetch /api/status while checking headless fallback state:', error); + } } } // If no saved credentials, we'll show Configure page (default state) diff --git a/frontend/components/ApiKeys.tsx b/frontend/components/ApiKeys.tsx index 7839f1a..a248ab5 100644 --- a/frontend/components/ApiKeys.tsx +++ b/frontend/components/ApiKeys.tsx @@ -226,6 +226,8 @@ const ApiKeys: React.FC = ({ authHeaders = {}, headlessMode = fals useEffect(() => { if (!isAdminUser) { initialAdminLoadRef.current = false + setKeys([]) + setIssuedKey(null) return } if (initialAdminLoadRef.current) return diff --git a/frontend/components/Configure.tsx b/frontend/components/Configure.tsx index 67a69ab..bdbdd2f 100644 --- a/frontend/components/Configure.tsx +++ b/frontend/components/Configure.tsx @@ -41,7 +41,7 @@ const defaultAdvancedSettings: AdvancedSettingsState = { interface ConfigureProps { onKeysetCreated: (data: { groupCredential: string; shareCredentials: string[]; name: string }) => void; - onCredentialsSaved?: () => void; + onCredentialsSaved?: () => void | Promise; onBack?: () => void; authHeaders?: Record; } @@ -337,7 +337,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav useEffect(() => { if (!showClearConfirm) return; const previousFocus = (document.activeElement as HTMLElement | null) ?? clearTriggerButtonRef.current; - const focusPrimary = () => clearConfirmButtonRef.current?.focus(); + const focusPrimary = () => clearCancelButtonRef.current?.focus(); const raf = window.requestAnimationFrame(focusPrimary); const handleKeyDown = (event: KeyboardEvent) => { @@ -569,7 +569,7 @@ const Configure: React.FC = ({ onKeysetCreated, onCredentialsSav // Notify parent component to refresh views if (onCredentialsSaved) { - onCredentialsSaved(); + await onCredentialsSaved(); } // Clear the form diff --git a/frontend/components/Login.tsx b/frontend/components/Login.tsx index 16a4a2b..e1f9c5c 100644 --- a/frontend/components/Login.tsx +++ b/frontend/components/Login.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Button } from './ui/button'; import { Input } from './ui/input'; import { PageLayout } from './ui/page-layout'; @@ -33,11 +33,7 @@ const Login: React.FC = ({ onLogin, authEnabled, updateInfo }) => { const [authStatus, setAuthStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(true); - useEffect(() => { - fetchAuthStatus(); - }, []); - - const fetchAuthStatus = async () => { + const fetchAuthStatus = useCallback(async () => { try { const response = await fetch('/api/auth/status'); if (response.ok) { @@ -62,7 +58,11 @@ const Login: React.FC = ({ onLogin, authEnabled, updateInfo }) => { } finally { setStatusLoading(false); } - }; + }, []); + + useEffect(() => { + void fetchAuthStatus(); + }, [fetchAuthStatus]); const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/components/NIP46.tsx b/frontend/components/NIP46.tsx index e93db5c..1f20958 100644 --- a/frontend/components/NIP46.tsx +++ b/frontend/components/NIP46.tsx @@ -107,14 +107,21 @@ export function NIP46({ authHeaders }: NIP46Props) { const fetchTransport = useCallback(async () => { try { const res = await fetch('/api/nip46/transport', { headers }) - if (res.ok) { - const data = await res.json() - if (typeof data?.transport_sk === 'string') { - setTransportKey(data.transport_sk) - setIsConnected(true) - } + if (!res.ok) { + setTransportKey(null) + setIsConnected(false) + return + } + const data = await res.json() + if (typeof data?.transport_sk === 'string') { + setTransportKey(data.transport_sk) + setIsConnected(true) + } else { + setTransportKey(null) + setIsConnected(false) } } catch { + setTransportKey(null) setIsConnected(false) } }, [headers]) @@ -127,8 +134,11 @@ export function NIP46({ authHeaders }: NIP46Props) { if (!targets.length) return setRequestsError(null) setRequestActionPending(true) + const extractErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : 'Failed to update request' + } try { - await Promise.all(targets.map(async target => { + const results = await Promise.allSettled(targets.map(async target => { const payload: Record = { id: target.id, action } if (options?.policyPatch) { payload.policy = options.policyPatch @@ -147,9 +157,14 @@ export function NIP46({ authHeaders }: NIP46Props) { if (options?.policyPatch) { await fetchSessions() } + const errors = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => extractErrorMessage(result.reason)) + if (errors.length > 0) { + setRequestsError(errors.join('; ')) + } } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to update request' - setRequestsError(message) + setRequestsError(extractErrorMessage(error)) } finally { setRequestActionPending(false) } @@ -411,17 +426,22 @@ export function NIP46({ authHeaders }: NIP46Props) {
-
- - {sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} -
+
+ + {sessions.length} {sessions.length === 1 ? 'session' : 'sessions'} +
{requests.filter(r => r.status === 'pending').length} pending requests
-
@@ -434,7 +454,13 @@ export function NIP46({ authHeaders }: NIP46Props) { {showFullKeys ? transportKey : truncate(transportKey, 8)} - diff --git a/frontend/components/Onboarding.tsx b/frontend/components/Onboarding.tsx index 38c473a..803bbae 100644 --- a/frontend/components/Onboarding.tsx +++ b/frontend/components/Onboarding.tsx @@ -560,7 +560,6 @@ const Onboarding: React.FC = ({ onComplete, initialSkipAdminVal onChange={(e) => setPassword(e.target.value)} disabled={isLoading} className="bg-gray-800/50 border-gray-700/50 text-blue-300 placeholder:text-gray-500" - pattern={PASSWORD_REGEX.source} title="Minimum 8 characters, with at least one uppercase letter, one lowercase letter, one number, and one special character" /> diff --git a/frontend/components/Recover.tsx b/frontend/components/Recover.tsx index 0bf659a..5ce8a71 100644 --- a/frontend/components/Recover.tsx +++ b/frontend/components/Recover.tsx @@ -98,6 +98,9 @@ const Recover: React.FC = ({ message: null }); const [isProcessing, setIsProcessing] = useState(false); + const [recoveredNsec, setRecoveredNsec] = useState(null); + const [showRecoveredNsec, setShowRecoveredNsec] = useState(false); + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); // Add state for the dynamic threshold const [currentThreshold, setCurrentThreshold] = useState(defaultThreshold); @@ -377,6 +380,9 @@ const Recover: React.FC = ({ if (!sharesFormValid) return; + setRecoveredNsec(null); + setShowRecoveredNsec(false); + setCopyStatus('idle'); setIsProcessing(true); try { // Get valid share credentials @@ -403,33 +409,33 @@ const Recover: React.FC = ({ } if (result.success) { + setRecoveredNsec(typeof result.nsec === 'string' ? result.nsec : null); setResult({ success: true, - message: ( -
-
- Successfully recovered NSEC using {result.details.sharesUsed} shares -
-
-
-
Recovered NSEC:
-
- {result.nsec} -
+ message: `Successfully recovered NSEC using ${result.details.sharesUsed} shares` + }); + if (Array.isArray(result.details.invalidShares) && result.details.invalidShares.length > 0) { + setResult({ + success: true, + message: ( +
+
+ Successfully recovered NSEC using {result.details.sharesUsed} shares +
+
+ Note: {result.details.invalidShares.length} invalid shares were ignored
- {result.details.invalidShares && ( -
- Note: {result.details.invalidShares.length} invalid shares were ignored -
- )}
-
- ) - }); + ) + }); + } } else { throw new Error(result.error || 'Recovery failed'); } } catch (error) { + setRecoveredNsec(null); + setShowRecoveredNsec(false); + setCopyStatus('idle'); setResult({ success: false, message: `Error recovering NSEC: ${error instanceof Error ? error.message : 'Unknown error'}` @@ -439,6 +445,17 @@ const Recover: React.FC = ({ } }; + const handleCopyRecoveredNsec = async () => { + if (!recoveredNsec) return; + try { + await navigator.clipboard.writeText(recoveredNsec); + setCopyStatus('copied'); + } catch (error) { + console.error('Failed to copy recovered NSEC:', error); + setCopyStatus('error'); + } + }; + return (
@@ -541,6 +558,32 @@ const Recover: React.FC = ({ result.success ? 'bg-green-900/30 text-green-200' : 'bg-red-900/30 text-red-200' }`}> {result.message} + {result.success && recoveredNsec && ( +
+
Recovered NSEC:
+
+ {showRecoveredNsec ? recoveredNsec : '••••••••••••••••••••••••••••••••'} +
+
+ + + {copyStatus === 'copied' && Copied} + {copyStatus === 'error' && Copy failed} +
+
+ )}
)}
diff --git a/frontend/components/Signer.tsx b/frontend/components/Signer.tsx index 2f2d3c0..0067841 100644 --- a/frontend/components/Signer.tsx +++ b/frontend/components/Signer.tsx @@ -49,6 +49,14 @@ const DEFAULT_RELAY = "wss://relay.primal.net"; const MAX_EVENT_LOG_IN_MEMORY = 10000; const AUTO_EXPAND_EVENT_TYPES: string[] = ['sign']; +function areRelayListsEqual(left: string[], right: string[]): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i += 1) { + if (left[i] !== right[i]) return false; + } + return true; +} + const sanitizeLogEntry = (entry: unknown): LogEntryData | null => { if (!entry || typeof entry !== "object") return null; const log = entry as Partial; @@ -182,11 +190,14 @@ const Signer = forwardRef(({ initialData, authHeaders const [loadingOlder, setLoadingOlder] = useState(false); const [downloadingLogs, setDownloadingLogs] = useState(false); const [realSelfPubkey, setRealSelfPubkey] = useState(null); + const relayMutationIdRef = useRef(0); + const relayUrlsRef = useRef([DEFAULT_RELAY]); // Reference for compatibility with parent component const nodeRef = useRef(null); const authHeadersRef = useRef(authHeaders); useEffect(() => { authHeadersRef.current = authHeaders; }, [authHeaders]); + useEffect(() => { relayUrlsRef.current = relayUrls; }, [relayUrls]); // Expose methods to parent components through ref useImperativeHandle(ref, () => ({ @@ -679,7 +690,7 @@ const Signer = forwardRef(({ initialData, authHeaders setRelayUrls(relays); } else { // If no valid relays found, save default relays - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } catch (error) { console.warn('Failed to parse RELAYS from env:', error); @@ -688,12 +699,12 @@ const Signer = forwardRef(({ initialData, authHeaders setRelayUrls([envVars.RELAYS]); } else { // Save default relays if parsing failed - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } } else { // If no RELAYS environment variable exists, save the default - saveRelaysToEnv([DEFAULT_RELAY]); + void saveRelaysToEnv([DEFAULT_RELAY]); } } catch (error) { console.error('Error fetching environment variables:', error); @@ -993,28 +1004,56 @@ const Signer = forwardRef(({ initialData, authHeaders }; // Save relay URLs (routes to appropriate endpoint based on mode) - const saveRelaysToEnv = async (relays: string[]) => { + const saveRelaysToEnv = async (relays: string[]): Promise => { if (isDatabaseMode()) { - await saveRelaysToUserCredentials(relays); + return await saveRelaysToUserCredentials(relays); } else { - await saveRelaysToServerEnv(relays); + return await saveRelaysToServerEnv(relays); } }; - const handleAddRelay = () => { - const isAlreadyAdded = relayUrls.indexOf(newRelayUrl) !== -1; - if (newRelayUrl && !isAlreadyAdded) { - const newRelays = [...relayUrls, newRelayUrl]; - setRelayUrls(newRelays); - setNewRelayUrl(""); - saveRelaysToEnv(newRelays); + const handleAddRelay = async () => { + const relayToAdd = newRelayUrl.trim(); + const currentRelays = relayUrlsRef.current; + const isAlreadyAdded = currentRelays.indexOf(relayToAdd) !== -1; + if (!relayToAdd || isAlreadyAdded) return; + + const previousRelays = currentRelays; + const newRelays = [...currentRelays, relayToAdd]; + const mutationId = ++relayMutationIdRef.current; + relayUrlsRef.current = newRelays; + setRelayUrls(newRelays); + setNewRelayUrl(""); + + const saved = await saveRelaysToEnv(newRelays); + if (!saved) { + const isLatestMutation = mutationId === relayMutationIdRef.current; + const relaysStillMatchFailedAttempt = areRelayListsEqual(relayUrlsRef.current, newRelays); + if (isLatestMutation && relaysStillMatchFailedAttempt) { + relayUrlsRef.current = previousRelays; + setRelayUrls(previousRelays); + setNewRelayUrl(relayToAdd); + } } }; - const handleRemoveRelay = (urlToRemove: string) => { - const newRelays = relayUrls.filter(url => url !== urlToRemove); + const handleRemoveRelay = async (urlToRemove: string) => { + const currentRelays = relayUrlsRef.current; + const newRelays = currentRelays.filter(url => url !== urlToRemove); + if (newRelays.length === currentRelays.length) return; + const previousRelays = currentRelays; + const mutationId = ++relayMutationIdRef.current; + relayUrlsRef.current = newRelays; setRelayUrls(newRelays); - saveRelaysToEnv(newRelays); + const saved = await saveRelaysToEnv(newRelays); + if (!saved) { + const isLatestMutation = mutationId === relayMutationIdRef.current; + const relaysStillMatchFailedAttempt = areRelayListsEqual(relayUrlsRef.current, newRelays); + if (isLatestMutation && relaysStillMatchFailedAttempt) { + relayUrlsRef.current = previousRelays; + setRelayUrls(previousRelays); + } + } }; // Expose the stopSigner method for compatibility (server-managed, no action needed) @@ -1303,7 +1342,7 @@ const Signer = forwardRef(({ initialData, authHeaders className="bg-gray-800/50 border-gray-700/50 text-blue-300 py-2 text-sm w-full" />
)} -
+ {!title && showCloseButton && ( +
+ +
+ )} +
{children}
@@ -82,4 +116,4 @@ const Modal: React.FC = ({ ); }; -export { Modal }; \ No newline at end of file +export { Modal }; diff --git a/frontend/components/ui/peer-list.tsx b/frontend/components/ui/peer-list.tsx index 1ba28b5..cbd891f 100644 --- a/frontend/components/ui/peer-list.tsx +++ b/frontend/components/ui/peer-list.tsx @@ -150,8 +150,8 @@ const derivePolicyState = ( }; const hasCustomPolicy = (policy: PeerPolicy): boolean => { - const sendOverride = typeof policy.allowSend === 'boolean' && policy.allowSend === false; - const receiveOverride = typeof policy.allowReceive === 'boolean' && policy.allowReceive === false; + const sendOverride = typeof policy.allowSend === 'boolean'; + const receiveOverride = typeof policy.allowReceive === 'boolean'; return sendOverride || receiveOverride; }; @@ -380,24 +380,34 @@ const PeerList: React.FC = ({ const updated = prev.map(peer => { // Try exact match first if (peer.pubkey === pubkey) { + const hasLatency = status.latency !== undefined && status.latency !== null; + const parsedLatency = Number(status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : peer.latency; + const parsedLastSeen = parseDate(status.lastSeen); + const parsedLastPingAttempt = parseDate(status.lastPingAttempt); return { ...peer, online: Boolean(status.online), - lastSeen: parseDate(status.lastSeen) ?? peer.lastSeen, - latency: status.latency ? Number(status.latency) : peer.latency, - lastPingAttempt: parseDate(status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus; } // Try match with compressed-prefix normalization (02/03) const peerNormalized = toPolicyKey(peer.pubkey); const pingNormalized = toPolicyKey(pubkey); if (peerNormalized !== '' && peerNormalized === pingNormalized) { + const hasLatency = status.latency !== undefined && status.latency !== null; + const parsedLatency = Number(status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : peer.latency; + const parsedLastSeen = parseDate(status.lastSeen); + const parsedLastPingAttempt = parseDate(status.lastPingAttempt); return { ...peer, online: Boolean(status.online), - lastSeen: parseDate(status.lastSeen) ?? peer.lastSeen, - latency: status.latency ? Number(status.latency) : peer.latency, - lastPingAttempt: parseDate(status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus; } return peer; @@ -439,15 +449,20 @@ const PeerList: React.FC = ({ const result = await response.json(); if (result.status) { + const hasLatency = result.status.latency !== undefined && result.status.latency !== null; + const parsedLatency = Number(result.status.latency); + const latency = hasLatency && Number.isFinite(parsedLatency) ? parsedLatency : null; + const parsedLastSeen = parseDate(result.status.lastSeen); + const parsedLastPingAttempt = parseDate(result.status.lastPingAttempt); // Update peer status immediately setPeers(prev => prev.map(peer => peer.pubkey === peerPubkey ? { ...peer, online: Boolean(result.status.online), - lastSeen: parseDate(result.status.lastSeen) ?? peer.lastSeen, - latency: result.status.latency ? Number(result.status.latency) : peer.latency, - lastPingAttempt: parseDate(result.status.lastPingAttempt) ?? peer.lastPingAttempt + lastSeen: parsedLastSeen ?? peer.lastSeen, + latency: latency ?? peer.latency, + lastPingAttempt: parsedLastPingAttempt ?? peer.lastPingAttempt } as PeerStatus : peer )); diff --git a/frontend/components/ui/relay-input.tsx b/frontend/components/ui/relay-input.tsx index df7b61a..e53506c 100644 --- a/frontend/components/ui/relay-input.tsx +++ b/frontend/components/ui/relay-input.tsx @@ -121,8 +121,8 @@ const RelayInput: React.FC = ({
- {relays.map((relay, index) => ( -
+ {relays.map((relay) => ( +
{relay}