import express from 'express'; import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; import { loadConfig, saveConfig } from '../shared/config.js'; import { paths, WORKSPACE_DIR } from '../shared/paths.js'; import { log } from '../shared/logger.js'; import { initDb, closeDb, listConversations, createConversation, deleteConversation, getMessages, addMessage, getSetting, getAllSettings, setSetting, createSession, getSession, deleteExpiredSessions, getRecentMessages, getMessagesBefore, addPushSubscription, removePushSubscription, getAllPushSubscriptions, getPushSubscriptionByEndpoint, createTrustedDevice, getTrustedDevice, updateDeviceLastSeen, listTrustedDevices, deleteTrustedDevice, deleteExpiredDevices, deleteAllTrustedDevices } from './db.js'; import webpush from 'web-push'; import { TOTP } from 'otpauth'; import QRCode from 'qrcode'; import { startCodexOAuth, cancelCodexOAuth, getCodexAuthStatus, exchangeCodexCode, startDeviceCodeLogin, getDeviceCodeStatus, cancelDeviceCodeLogin, } from './codex-auth.js'; import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js'; import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js'; import { ensureFileDirs } from '../supervisor/file-saver.js'; import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js'; import { runPiTestCompletion, runPiStreamProbe } from '../supervisor/harnesses/pi/test-completion.js'; import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js'; // ── Password hashing (scrypt) ── function hashPassword(password: string): string { const salt = crypto.randomBytes(16).toString('hex'); const hash = crypto.scryptSync(password, salt, 64).toString('hex'); return `${salt}:${hash}`; } function verifyPassword(password: string, stored: string): boolean { const [salt, hash] = stored.split(':'); const test = crypto.scryptSync(password, salt, 64).toString('hex'); return hash === test; } // ── TOTP helpers ── function generateTOTPSecret(): string { return crypto.randomBytes(20).toString('base64url').replace(/[^A-Z2-7]/gi, '').slice(0, 32).toUpperCase(); } function verifyTOTPCode(code: string, secret: string): boolean { const totp = new TOTP({ issuer: 'Bloby', algorithm: 'SHA1', digits: 6, period: 30, secret }); const delta = totp.validate({ token: code, window: 1 }); return delta !== null; } function generateRecoveryCodes(): string[] { const codes: string[] = []; for (let i = 0; i < 8; i++) { codes.push(crypto.randomBytes(4).toString('hex')); } return codes; } function hashRecoveryCode(code: string): string { return crypto.createHash('sha256').update(code.toLowerCase()).digest('hex'); } function verifyRecoveryCode(code: string, hashes: string[]): { valid: boolean; remaining: string[] } { const h = hashRecoveryCode(code); const idx = hashes.indexOf(h); if (idx === -1) return { valid: false, remaining: hashes }; const remaining = [...hashes]; remaining.splice(idx, 1); return { valid: true, remaining }; } function parseCookie(cookieHeader: string | undefined, name: string): string | undefined { if (!cookieHeader) return undefined; const match = cookieHeader.split(';').map(c => c.trim()).find(c => c.startsWith(`${name}=`)); return match ? match.slice(name.length + 1) : undefined; } export function createWorkerApp() { // Database initDb(); // Ensure file storage directories exist ensureFileDirs(); // ── VAPID key management (Web Push) ── function getOrCreateVapidKeys() { let publicKey = getSetting('vapid_public_key'); let privateKey = getSetting('vapid_private_key'); if (!publicKey || !privateKey) { const keys = webpush.generateVAPIDKeys(); publicKey = keys.publicKey; privateKey = keys.privateKey; setSetting('vapid_public_key', publicKey); setSetting('vapid_private_key', privateKey); log.ok('Generated new VAPID keys'); } return { publicKey, privateKey }; } function initWebPush() { const { publicKey, privateKey } = getOrCreateVapidKeys(); webpush.setVapidDetails('mailto:push@bloby.bot', publicKey, privateKey); log.ok('Web Push initialized'); } initWebPush(); // Express const app = express(); app.use(express.json({ limit: '10mb' })); // Prevent browsers/CDN/relay from caching API responses (avoids stale 502s) app.use('/api', (_, res, next) => { res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); res.set('Surrogate-Control', 'no-store'); next(); }); app.get('/api/health', (_, res) => res.json({ status: 'ok' })); app.get('/api/conversations', (_, res) => res.json(listConversations())); app.get('/api/conversations/:id', (req, res) => { const msgs = getMessages(req.params.id); res.json({ id: req.params.id, messages: msgs }); }); app.post('/api/conversations', (req, res) => { const { title, model } = req.body || {}; const conv = createConversation(title, model); res.json(conv); }); app.post('/api/conversations/:id/messages', (req, res) => { const { role, content, meta } = req.body || {}; if (!role || !content) { res.status(400).json({ error: 'Missing role or content' }); return; } const msg = addMessage(req.params.id, role, content, meta); res.json(msg); }); app.get('/api/conversations/:id/messages/recent', (req, res) => { const limit = parseInt(req.query.limit as string) || 20; const msgs = getRecentMessages(req.params.id, Math.min(limit, 1000)); res.json(msgs); }); app.get('/api/conversations/:id/messages', (req, res) => { const before = req.query.before as string; const limit = Math.min(parseInt(req.query.limit as string) || 20, 1000); if (before) { res.json(getMessagesBefore(req.params.id, before, limit)); } else { res.json(getRecentMessages(req.params.id, limit)); } }); app.delete('/api/conversations/:id', (req, res) => { deleteConversation(req.params.id); res.json({ ok: true }); }); app.get('/api/settings', (_, res) => { // SECURITY: GET /api/settings is reachable UNAUTHENTICATED — the supervisor auth gate // skips GET/HEAD, and the public relay handle (bloby.bot/) proxies straight // through to here. So this response must never carry credentials/secrets. Strip them. // The known consumers (widget.js, bloby-main, workspace App.tsx) read only non-secret // keys (onboard flags, user_name, agent_name, whisper_enabled); "is a password set" is // exposed separately as portalConfigured on /api/onboard/status. const SECRET_KEYS = new Set([ 'portal_pass', 'totp_secret', 'totp_pending_secret', 'totp_recovery_codes', 'whisper_key', 'vapid_private_key', ]); const safe: Record = {}; for (const [k, v] of Object.entries(getAllSettings())) { if (SECRET_KEYS.has(k) || k.startsWith('totp_pending_login')) continue; safe[k] = v as string; } res.json(safe); }); app.put('/api/settings/:key', (req, res) => { setSetting(req.params.key, req.body.value); res.json({ ok: true }); }); // ── Current conversation (shared across devices) ── app.get('/api/context/current', (_, res) => { const convId = getSetting('current_conversation'); res.json({ conversationId: convId || null }); }); app.post('/api/context/set', (req, res) => { const { conversationId } = req.body; if (conversationId) { setSetting('current_conversation', conversationId); } res.json({ ok: true }); }); app.post('/api/context/clear', (_, res) => { setSetting('current_conversation', ''); res.json({ ok: true }); }); // ── Codex OAuth routes ── app.post('/api/auth/codex/start', (_req, res) => { res.json(startCodexOAuth()); }); app.post('/api/auth/codex/exchange', async (req, res) => { const { code } = req.body || {}; if (!code || typeof code !== 'string') { res.json({ success: false, error: 'No code provided' }); return; } res.json(await exchangeCodexCode(code)); }); app.post('/api/auth/codex/cancel', (_req, res) => { cancelCodexOAuth(); res.json({ ok: true }); }); app.get('/api/auth/codex/status', async (_req, res) => { res.json(await getCodexAuthStatus()); }); // ── Codex device-code routes (preferred for headless dashboards) ── app.post('/api/auth/codex/device/start', async (_req, res) => { res.json(await startDeviceCodeLogin()); }); app.get('/api/auth/codex/device/status', (_req, res) => { res.json(getDeviceCodeStatus()); }); app.post('/api/auth/codex/device/cancel', (_req, res) => { cancelDeviceCodeLogin(); res.json({ ok: true }); }); // ── Claude OAuth routes ── app.post('/api/auth/claude/start', (_req, res) => { res.json(startClaudeOAuth()); }); app.post('/api/auth/claude/exchange', async (req, res) => { const { code } = req.body; if (!code) { res.json({ success: false, error: 'No code provided' }); return; } const result = await exchangeClaudeCode(code); res.json(result); }); app.get('/api/auth/claude/status', async (_req, res) => { res.json(await getClaudeAuthStatus()); }); // ── Pi (Bloby third harness) auth routes ── app.get('/api/auth/pi/providers', (_req, res) => { res.json({ providers: PI_SUB_PROVIDERS.map((p) => ({ id: p.id, name: p.name, subtitle: p.subtitle, flavor: p.flavor, baseUrl: p.baseUrl, needsBaseUrl: !!p.needsBaseUrl, needsApiKey: p.needsApiKey !== false, apiKeyUrl: p.apiKeyUrl, models: p.models, defaultModel: p.defaultModel, })), }); }); app.get('/api/auth/pi/status', (_req, res) => { res.json(getPiAuthStatus()); }); app.post('/api/auth/pi/test', async (req, res) => { const { subProvider, apiKey, baseUrl, modelId } = req.body || {}; if (!subProvider || typeof subProvider !== 'string') { res.json({ ok: false, error: 'Missing subProvider' }); return; } const provider = getPiSubProvider(subProvider); if (!provider) { res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` }); return; } const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.'; const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt }); if (!result.ok) { res.json(result); return; } // Second tier (audit C-4): real turns stream SSE with the full tool schema — // free-form model ids (Ollama/LM Studio/custom/OpenRouter) can pass the basic // call and then fail the first actual message. Probe the real wire shape so // the wizard's green check means chat will actually work. const probe = await runPiStreamProbe({ subProvider, apiKey, baseUrl, modelId, prompt }); if (!probe.ok) { res.json({ ...probe, ok: false, error: `The endpoint responds, but streaming with tools failed (live chat would break): ${probe.error}`, }); return; } res.json(result); }); app.post('/api/auth/pi/save', (req, res) => { const { subProvider, apiKey, baseUrl, modelId } = req.body || {}; if (!subProvider || typeof subProvider !== 'string') { res.json({ ok: false, error: 'Missing subProvider' }); return; } const provider = getPiSubProvider(subProvider); if (!provider) { res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` }); return; } const saved = writePiAuth({ subProvider, apiKey: typeof apiKey === 'string' ? apiKey : undefined, baseUrl: typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : provider.baseUrl, modelId: typeof modelId === 'string' && modelId.trim() ? modelId.trim() : provider.defaultModel, }); res.json({ ok: true, status: { configured: true, subProvider: saved.subProvider, modelId: saved.modelId, baseUrl: saved.baseUrl } }); }); app.delete('/api/auth/pi', (_req, res) => { clearPiAuth(); res.json({ ok: true }); }); app.post('/api/auth/pi/completion', async (req, res) => { const auth = readPiAuth(); if (!auth) { res.json({ ok: false, error: 'Bloby provider is not configured yet' }); return; } const prompt = (req.body?.prompt && typeof req.body.prompt === 'string') ? req.body.prompt : 'In one short sentence, introduce yourself as the model behind this endpoint and confirm the connection is working.'; const result = await runPiTestCompletion({ subProvider: auth.subProvider, apiKey: auth.apiKey, baseUrl: auth.baseUrl, modelId: auth.modelId, prompt, }); res.json(result); }); // ── Handle registration ── app.get('/api/handle/check/:username', async (req, res) => { try { const result = await checkAvailability(req.params.username); res.json(result); } catch { res.json({ available: false, valid: false, error: 'Could not reach relay server' }); } }); app.get('/api/handle/status', (_req, res) => { const cfg = loadConfig(); res.json({ registered: !!cfg.relay?.token, username: cfg.username || '', tier: cfg.relay?.tier || '', url: cfg.relay?.url || '', }); }); app.post('/api/handle/register', async (req, res) => { const { username, tier } = req.body; log.ok(`Handle register request: username=${username}, tier=${tier}`); if (!username || !tier) { res.status(400).json({ error: 'Missing username or tier' }); return; } try { const cfg = loadConfig(); const result = await registerHandle(username, tier, cfg.wallet?.address); log.ok(`Handle registered with relay: url=${result.relayUrl}, token=${result.token ? 'received' : 'MISSING'}`); // Save to config cfg.username = username; cfg.relay = { token: result.token, tier, url: result.relayUrl }; saveConfig(cfg); log.ok(`Handle registered: ${result.relayUrl}`); // If the tunnel is already running, push the URL to the relay immediately if (cfg.tunnelUrl) { try { await updateTunnelUrl(result.token, cfg.tunnelUrl); startHeartbeat(result.token, cfg.tunnelUrl); } catch (err: any) { log.warn(`Relay update: ${err.message}`); } } res.json({ ok: true, url: result.relayUrl }); } catch (err: any) { res.status(400).json({ error: err.message || 'Registration failed' }); } }); app.post('/api/handle/change', async (req, res) => { const { username, tier } = req.body; log.ok(`Handle change request: new=${username}, tier=${tier}`); if (!username || !tier) { res.status(400).json({ error: 'Missing username or tier' }); return; } try { const cfg = loadConfig(); log.ok(`Handle change: old username=${cfg.username || '(none)'}, old token=${cfg.relay?.token ? 'yes' : 'NO — cannot release!'}`); // Release old handle if one exists if (cfg.relay?.token) { stopHeartbeat(); try { await releaseHandle(cfg.relay.token); log.ok(`Released old handle: ${cfg.username}`); } catch (err: any) { log.warn(`Release old handle: ${err.message}`); } } else { log.warn('Handle change: no existing token found — old handle will NOT be released (orphaned in MongoDB)'); } // Register new handle const result = await registerHandle(username, tier, cfg.wallet?.address); cfg.username = username; cfg.relay = { token: result.token, tier, url: result.relayUrl }; saveConfig(cfg); log.ok(`Handle changed to: ${result.relayUrl}`); // Push tunnel URL to new handle if (cfg.tunnelUrl) { try { await updateTunnelUrl(result.token, cfg.tunnelUrl); startHeartbeat(result.token, cfg.tunnelUrl); } catch (err: any) { log.warn(`Relay update: ${err.message}`); } } res.json({ ok: true, url: result.relayUrl }); } catch (err: any) { res.status(400).json({ error: err.message || 'Handle change failed' }); } }); app.post('/api/handle/claim-reserved', async (req, res) => { const { handle, hash } = req.body; if (!handle || !hash) { res.status(400).json({ error: 'Missing handle or activation code' }); return; } try { const cfg = loadConfig(); const result = await claimReservedHandle(handle, hash, cfg.wallet?.address); // Release old handle if one exists if (cfg.relay?.token) { stopHeartbeat(); try { await releaseHandle(cfg.relay.token); } catch (err: any) { log.warn(`Release old handle: ${err.message}`); } } cfg.username = handle; cfg.relay = { token: result.token, tier: 'premium', url: result.relayUrl }; saveConfig(cfg); log.ok(`Reserved handle claimed: ${result.relayUrl}`); // Push tunnel URL if running if (cfg.tunnelUrl) { try { await updateTunnelUrl(result.token, cfg.tunnelUrl); startHeartbeat(result.token, cfg.tunnelUrl); } catch (err: any) { log.warn(`Relay update: ${err.message}`); } } res.json({ ok: true, url: result.relayUrl }); } catch (err: any) { res.status(400).json({ error: err.message || 'Claim failed' }); } }); // ── Wallet ── // USDC contracts on each network. Same EOA can hold balances on both — // wallet generation uses an Ethereum-compatible private key, so the address is // identical across Tempo and Base. const USDC_TEMPO = '0x20c000000000000000000000b9537d11c60e8b50'; const USDC_BASE = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913'; const ERC20_BALANCE_OF_ABI = [{ name: 'balanceOf', type: 'function', stateMutability: 'view', inputs: [{ name: 'account', type: 'address' }], outputs: [{ name: '', type: 'uint256' }], }] as const; app.get('/api/wallet/balance', async (_req, res) => { const cfg = loadConfig(); if (!cfg.wallet?.address) { res.json({ address: null, tempo: '0.00', base: '0.00' }); return; } const address = cfg.wallet.address as `0x${string}`; try { const { createPublicClient, http, formatUnits } = await import('viem'); const { tempo, base } = await import('viem/chains'); const tempoClient = createPublicClient({ chain: tempo, transport: http('https://rpc.tempo.xyz') }); const baseClient = createPublicClient({ chain: base, transport: http() }); const [tempoBal, baseBal] = await Promise.all([ tempoClient.readContract({ address: USDC_TEMPO, abi: ERC20_BALANCE_OF_ABI, functionName: 'balanceOf', args: [address], }).catch((err) => { log.warn(`Tempo balance failed: ${err.message}`); return 0n; }), baseClient.readContract({ address: USDC_BASE, abi: ERC20_BALANCE_OF_ABI, functionName: 'balanceOf', args: [address], }).catch((err) => { log.warn(`Base balance failed: ${err.message}`); return 0n; }), ]); res.json({ address, tempo: formatUnits(tempoBal, 6), base: formatUnits(baseBal, 6), }); } catch (err: any) { log.warn(`Wallet balance check failed: ${err.message}`); res.json({ address, tempo: '0.00', base: '0.00' }); } }); // ── Onboarding ── app.get('/api/onboard/status', (_, res) => { const settings = getAllSettings(); const cfg = loadConfig(); const hasToken = !!cfg.relay?.token; res.json({ userName: settings.user_name || '', agentName: settings.agent_name || '', portalUser: settings.portal_user || '', portalConfigured: !!settings.portal_pass, whisperEnabled: settings.whisper_enabled === 'true', whisperKey: settings.whisper_key || '', provider: cfg.ai?.provider || '', model: cfg.ai?.model || '', handle: hasToken ? { username: cfg.username, tier: cfg.relay.tier, url: cfg.relay.url, } : null, tunnelMode: cfg.tunnel?.mode || 'quick', tunnelDomain: cfg.tunnel?.domain || '', tunnelUrl: cfg.tunnelUrl || '', totpEnabled: settings.totp_enabled === 'true', }); }); app.post('/api/portal/verify-password', (req, res) => { const { password } = req.body; const stored = getSetting('portal_pass'); if (!stored) { res.json({ valid: false, error: 'No password set' }); return; } res.json({ valid: verifyPassword(password, stored) }); }); // Shared login logic (used by both POST and GET handlers) function handleLogin(username: string | undefined, password: string | undefined, req: any, res: any) { if (!password) { res.status(400).json({ error: 'Password required' }); return; } const storedPass = getSetting('portal_pass'); if (!storedPass) { res.status(400).json({ error: 'No password set' }); return; } if (!verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; } // Check if TOTP is enabled const totpEnabled = getSetting('totp_enabled') === 'true'; if (totpEnabled) { // Check for trusted device cookie const deviceToken = parseCookie(req.headers.cookie, 'bloby_device'); if (deviceToken) { const device = getTrustedDevice(deviceToken); if (device) { updateDeviceLastSeen(deviceToken); // Trusted device — skip TOTP deleteExpiredSessions(); const token = crypto.randomBytes(64).toString('hex'); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); createSession(token, expiresAt); res.json({ token, expiresAt }); return; } } // No valid trusted device — require TOTP const pendingToken = crypto.randomBytes(32).toString('hex'); const pendingExpiry = new Date(Date.now() + 5 * 60 * 1000).toISOString(); setSetting(`totp_pending_login:${pendingToken}`, pendingExpiry); res.json({ requiresTOTP: true, pendingToken }); return; } deleteExpiredSessions(); const token = crypto.randomBytes(64).toString('hex'); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); createSession(token, expiresAt); res.json({ token, expiresAt }); } // POST: credentials in JSON body app.post('/api/portal/login', (req, res) => { const { username, password } = req.body; handleLogin(username, password, req, res); }); // GET: credentials via Authorization Basic header (relay proxies don't forward POST bodies) app.get('/api/portal/login', (req, res) => { const authHeader = req.headers['authorization']; if (!authHeader?.startsWith('Basic ')) { res.status(400).json({ error: 'Authorization header required' }); return; } const decoded = Buffer.from(authHeader.slice(6), 'base64').toString(); const sep = decoded.indexOf(':'); if (sep < 0) { res.status(400).json({ error: 'Invalid credentials format' }); return; } handleLogin(decoded.slice(0, sep), decoded.slice(sep + 1), req, res); }); // POST + GET for validate-token (same relay issue) function handleValidateToken(token: string | undefined, res: any) { if (!token) { res.json({ valid: false }); return; } const session = getSession(token); res.json({ valid: !!session }); } app.post('/api/portal/validate-token', (req, res) => { handleValidateToken(req.body.token, res); }); app.get('/api/portal/validate-token', (req, res) => { handleValidateToken(req.query.token as string, res); }); // ── TOTP 2FA endpoints ── app.get('/api/portal/totp/status', (_req, res) => { res.json({ enabled: getSetting('totp_enabled') === 'true' }); }); app.post('/api/portal/totp/setup', async (req, res) => { // Verify caller has auth: session token, correct password, or initial onboard (no password set yet) const authHeader = req.headers['authorization']; let authorized = false; if (authHeader?.startsWith('Bearer ')) { const session = getSession(authHeader.slice(7)); if (session) authorized = true; } if (!authorized && req.body?.password) { const storedPass = getSetting('portal_pass'); if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true; } // During initial onboard, no password is stored yet — allow setup if (!authorized && !getSetting('portal_pass')) authorized = true; if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; } const secret = generateTOTPSecret(); setSetting('totp_pending_secret', secret); const botName = getSetting('agent_name') || 'Bloby'; const totp = new TOTP({ issuer: 'Bloby', label: botName, algorithm: 'SHA1', digits: 6, period: 30, secret }); const otpauthUri = totp.toString(); try { const qrDataUri = await QRCode.toDataURL(otpauthUri, { width: 256, margin: 2 }); res.json({ secret, qrDataUri, otpauthUri }); } catch (err: any) { res.status(500).json({ error: 'Failed to generate QR code' }); } }); app.post('/api/portal/totp/verify-setup', (req, res) => { const authHeader = req.headers['authorization']; let authorized = false; if (authHeader?.startsWith('Bearer ')) { const session = getSession(authHeader.slice(7)); if (session) authorized = true; } if (!authorized && req.body?.password) { const storedPass = getSetting('portal_pass'); if (storedPass && verifyPassword(req.body.password, storedPass)) authorized = true; } // During initial onboard, no password is stored yet — allow verify if (!authorized && !getSetting('portal_pass')) authorized = true; if (!authorized) { res.status(401).json({ error: 'Unauthorized' }); return; } const { code } = req.body; if (!code) { res.status(400).json({ error: 'Code required' }); return; } const pendingSecret = getSetting('totp_pending_secret'); if (!pendingSecret) { res.status(400).json({ error: 'No TOTP setup in progress' }); return; } if (!verifyTOTPCode(code, pendingSecret)) { res.status(400).json({ error: 'Invalid code. Check your authenticator app and try again.' }); return; } // Success: persist TOTP config setSetting('totp_secret', pendingSecret); setSetting('totp_enabled', 'true'); // Generate recovery codes const codes = generateRecoveryCodes(); const hashes = codes.map(hashRecoveryCode); setSetting('totp_recovery_codes', JSON.stringify(hashes)); // Clean up pending secret setSetting('totp_pending_secret', ''); res.json({ success: true, recoveryCodes: codes }); }); app.post('/api/portal/totp/disable', (req, res) => { const { password, code } = req.body; if (!password || !code) { res.status(400).json({ error: 'Password and TOTP code required' }); return; } const storedPass = getSetting('portal_pass'); if (!storedPass || !verifyPassword(password, storedPass)) { res.status(401).json({ error: 'Invalid password' }); return; } const secret = getSetting('totp_secret'); if (!secret) { res.status(400).json({ error: '2FA is not enabled' }); return; } if (!verifyTOTPCode(code, secret)) { res.status(400).json({ error: 'Invalid TOTP code' }); return; } // Clear all TOTP settings setSetting('totp_enabled', 'false'); setSetting('totp_secret', ''); setSetting('totp_recovery_codes', ''); setSetting('totp_pending_secret', ''); deleteAllTrustedDevices(); res.json({ success: true }); }); app.get('/api/portal/login/totp', (req, res) => { const pending = req.query.pending as string; const code = req.query.code as string; const trust = req.query.trust as string; if (!pending || !code) { res.status(400).json({ error: 'Missing pending token or code' }); return; } // Validate pending token const expiry = getSetting(`totp_pending_login:${pending}`); if (!expiry || new Date(expiry) < new Date()) { res.status(401).json({ error: 'Login session expired. Please start over.' }); return; } // Clean up pending token setSetting(`totp_pending_login:${pending}`, ''); const secret = getSetting('totp_secret'); if (!secret) { res.status(400).json({ error: '2FA is not configured' }); return; } // Try TOTP code first let valid = verifyTOTPCode(code, secret); // If not valid as TOTP, try as recovery code if (!valid) { const hashesJson = getSetting('totp_recovery_codes'); if (hashesJson) { try { const hashes = JSON.parse(hashesJson) as string[]; const result = verifyRecoveryCode(code, hashes); if (result.valid) { valid = true; setSetting('totp_recovery_codes', JSON.stringify(result.remaining)); } } catch {} } } if (!valid) { res.status(401).json({ error: 'Invalid code' }); return; } // Create session deleteExpiredSessions(); const token = crypto.randomBytes(64).toString('hex'); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(); createSession(token, expiresAt); // Trust device if requested if (trust === '1') { deleteExpiredDevices(); const deviceToken = crypto.randomBytes(32).toString('hex'); const deviceExpiry = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); createTrustedDevice(deviceToken, 'Browser', deviceExpiry); res.setHeader('Set-Cookie', `bloby_device=${deviceToken}; HttpOnly; Secure; SameSite=Strict; Max-Age=7776000; Path=/`); } res.json({ token, expiresAt }); }); app.get('/api/portal/devices', (_req, res) => { res.json(listTrustedDevices()); }); app.delete('/api/portal/devices/:id', (req, res) => { deleteTrustedDevice(req.params.id); res.json({ ok: true }); }); app.post('/api/portal/devices/revoke', (req, res) => { const { id } = req.body; if (!id) { res.status(400).json({ error: 'Device ID required' }); return; } deleteTrustedDevice(id); res.json({ ok: true }); }); app.post('/api/onboard', (req, res) => { const { userName, agentName, provider, model, apiKey, baseUrl, portalUser, portalPass, whisperEnabled, whisperKey } = req.body; // Read old names before overwriting (needed for BLOBY.md re-onboard) const oldBotName = getSetting('agent_name') || '$BOT'; const oldHumanName = getSetting('user_name') || '$HUMAN'; setSetting('user_name', userName || ''); setSetting('agent_name', agentName || 'Bloby'); setSetting('onboard_complete', 'true'); // Save portal credentials if (portalUser) { setSetting('portal_user', portalUser.trim().toLowerCase()); } if (portalPass) { setSetting('portal_pass', hashPassword(portalPass)); } // Save whisper config setSetting('whisper_enabled', whisperEnabled ? 'true' : 'false'); if (whisperKey) { setSetting('whisper_key', whisperKey); } // Update bot and human names in BLOBY.md // On first onboard: replaces $BOT / $HUMAN placeholders // On re-onboard: replaces the previous names with the new ones const newBotName = agentName || 'Bloby'; const newHumanName = userName || 'Human'; const blobyMdPath = path.join(WORKSPACE_DIR, 'BLOBY.md'); if (fs.existsSync(blobyMdPath)) { let blobyContent = fs.readFileSync(blobyMdPath, 'utf-8'); if (oldBotName !== newBotName) { blobyContent = blobyContent.replace(new RegExp(oldBotName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newBotName); } if (oldHumanName !== newHumanName) { blobyContent = blobyContent.replace(new RegExp(oldHumanName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), newHumanName); } fs.writeFileSync(blobyMdPath, blobyContent, 'utf-8'); log.ok(`BLOBY.md: updated names — bot="${newBotName}", human="${newHumanName}"`); } // Re-read config from disk to preserve relay/handle data written by registration const currentCfg = loadConfig(); log.ok(`Onboard: preserving relay data — token=${currentCfg.relay?.token ? 'yes' : 'no'}, username=${currentCfg.username || '(none)'}`); currentCfg.ai.provider = provider || ''; currentCfg.ai.model = model || ''; currentCfg.ai.baseUrl = baseUrl || undefined; // OAuth providers store their tokens in their own credentials files // (~/.codex/auth.json, ~/.claude/.credentials.json) and refresh them as // needed — config.ai.apiKey only holds raw API keys. if (!apiKey && provider === 'anthropic') { currentCfg.ai.apiKey = readClaudeAccessToken() || ''; } else { currentCfg.ai.apiKey = apiKey || ''; } saveConfig(currentCfg); res.json({ ok: true }); }); // ── Push notifications ── app.get('/api/push/vapid-public-key', (_, res) => { const key = getSetting('vapid_public_key'); if (!key) { res.status(500).json({ error: 'VAPID keys not initialized' }); return; } res.json({ publicKey: key }); }); app.post('/api/push/subscribe', (req, res) => { const { endpoint, keys } = req.body || {}; if (!endpoint || !keys?.p256dh || !keys?.auth) { res.status(400).json({ error: 'Invalid subscription' }); return; } addPushSubscription(endpoint, keys.p256dh, keys.auth); res.json({ ok: true }); }); app.delete('/api/push/unsubscribe', (req, res) => { const { endpoint } = req.body || {}; if (!endpoint) { res.status(400).json({ error: 'Missing endpoint' }); return; } removePushSubscription(endpoint); res.json({ ok: true }); }); app.post('/api/push/send', async (req, res) => { const { title, body, tag, url } = req.body || {}; const subs = getAllPushSubscriptions(); log.info(`[push] Sending to ${subs.length} subscription(s): "${title}"`); const payload = JSON.stringify({ title: title || 'Bloby', body: body || '', tag: tag || 'bloby', url: url || '/' }); const results = await Promise.allSettled( subs.map(async (sub) => { try { await webpush.sendNotification( { endpoint: sub.endpoint, keys: { p256dh: sub.keys_p256dh, auth: sub.keys_auth } }, payload, ); } catch (err: any) { if (err.statusCode === 410 || err.statusCode === 404) { removePushSubscription(sub.endpoint); log.info(`[push] Removed expired subscription: ${sub.endpoint.slice(0, 60)}...`); } else { log.warn(`[push] Send failed: ${err.message}`); } throw err; } }), ); const sent = results.filter((r) => r.status === 'fulfilled').length; res.json({ sent, total: subs.length }); }); app.get('/api/push/status', (req, res) => { const endpoint = req.query.endpoint as string; if (!endpoint) { res.json({ subscribed: false }); return; } const sub = getPushSubscriptionByEndpoint(endpoint); res.json({ subscribed: !!sub }); }); // ── Whisper transcription ── app.post('/api/whisper/transcribe', express.json({ limit: '10mb' }), async (req, res) => { const whisperEnabled = getSetting('whisper_enabled'); const whisperKey = getSetting('whisper_key'); if (whisperEnabled !== 'true' || !whisperKey) { res.status(400).json({ error: 'Whisper not enabled or API key missing' }); return; } const { audio } = req.body; // base64 string (no data URL prefix expected, but handle both) if (!audio) { res.status(400).json({ error: 'No audio data provided' }); return; } try { // Strip data URL prefix if present const raw = audio.includes(',') ? audio.split(',')[1] : audio; const audioBuffer = Buffer.from(raw, 'base64'); // Build multipart/form-data manually const boundary = '----WhisperBoundary' + Date.now(); const CRLF = '\r\n'; const parts: Buffer[] = []; // file part parts.push(Buffer.from( `--${boundary}${CRLF}` + `Content-Disposition: form-data; name="file"; filename="audio.webm"${CRLF}` + `Content-Type: audio/webm${CRLF}${CRLF}` )); parts.push(audioBuffer); parts.push(Buffer.from(CRLF)); // model part parts.push(Buffer.from( `--${boundary}${CRLF}` + `Content-Disposition: form-data; name="model"${CRLF}${CRLF}` + `whisper-1${CRLF}` )); // closing boundary parts.push(Buffer.from(`--${boundary}--${CRLF}`)); const body = Buffer.concat(parts); const response = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { 'Authorization': `Bearer ${whisperKey}`, 'Content-Type': `multipart/form-data; boundary=${boundary}`, }, body, }); if (!response.ok) { const errText = await response.text(); log.warn(`Whisper API error: ${response.status} ${errText}`); res.status(502).json({ error: 'Whisper API error' }); return; } const result = await response.json() as { text: string }; res.json({ transcript: result.text }); } catch (err: any) { log.warn(`Whisper transcription failed: ${err.message}`); res.status(500).json({ error: 'Transcription failed' }); } }); // Serve stored files (audio, images, documents) app.use('/api/files', express.static(paths.files)); log.ok('Worker routes initialized (in-process)'); return app; }