phone-relay-server.js
1 #!/usr/bin/env node 2 /** 3 * Phone Relay Server - HTTP endpoint for curl-based publishing 4 * 5 * Runs on desktop, accepts plain HTTP POST, handles encryption + Nostr. 6 * Phone just needs curl - no Node, no crypto libraries. 7 * 8 * Usage: 9 * node phone-relay-server.js # Start server on :7777 10 * node phone-relay-server.js --port 8080 # Custom port 11 * 12 * From phone: 13 * curl -X POST http://macbook:7777/publish -d "message here" 14 * curl -X POST http://macbook:7777/context -H "Content-Type: application/json" -d '{"focus":"auth"}' 15 */ 16 17 import http from 'http' 18 import crypto from 'crypto' 19 import fs from 'fs' 20 import path from 'path' 21 import os from 'os' 22 import { finalizeEvent } from 'nostr-tools' 23 import { Relay } from 'nostr-tools/relay' 24 25 // Load keys 26 const CONFIG_DIR = path.join(os.homedir(), '.sovereign') 27 const KEY_FILE = path.join(CONFIG_DIR, 'broadcast-key.json') 28 const NOSTR_KEY_FILE = path.join(CONFIG_DIR, 'nostr-identity.json') 29 30 const RELAYS = ['wss://relay.damus.io', 'wss://nos.lol', 'wss://nostr.wine'] 31 const EVENT_KIND = 30078 32 33 let sovereignKey, nostrSecretKey, identifier 34 35 function loadKeys() { 36 const keyData = JSON.parse(fs.readFileSync(KEY_FILE, 'utf8')) 37 sovereignKey = Buffer.from(keyData.key, 'hex') 38 identifier = keyData.identifier 39 40 const nostrData = JSON.parse(fs.readFileSync(NOSTR_KEY_FILE, 'utf8')) 41 nostrSecretKey = Buffer.from(nostrData.secretKey, 'hex') 42 } 43 44 function encrypt(plaintext) { 45 const iv = crypto.randomBytes(12) 46 const cipher = crypto.createCipheriv('aes-256-gcm', sovereignKey, iv) 47 let encrypted = cipher.update(plaintext, 'utf8', 'base64') 48 encrypted += cipher.final('base64') 49 const authTag = cipher.getAuthTag() 50 return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}` 51 } 52 53 async function publishToNostr(payload) { 54 const encrypted = encrypt(JSON.stringify(payload)) 55 56 const event = finalizeEvent({ 57 kind: EVENT_KIND, 58 created_at: Math.floor(Date.now() / 1000), 59 tags: [['d', identifier]], 60 content: encrypted 61 }, nostrSecretKey) 62 63 let success = 0 64 for (const url of RELAYS) { 65 try { 66 const relay = await Relay.connect(url) 67 await relay.publish(event) 68 relay.close() 69 success++ 70 } catch (err) { 71 // Continue 72 } 73 } 74 return success 75 } 76 77 function parseBody(req) { 78 return new Promise((resolve, reject) => { 79 let body = '' 80 req.on('data', chunk => body += chunk) 81 req.on('end', () => resolve(body)) 82 req.on('error', reject) 83 }) 84 } 85 86 async function handleRequest(req, res) { 87 const url = new URL(req.url, `http://${req.headers.host}`) 88 89 // CORS for browser-based clients 90 res.setHeader('Access-Control-Allow-Origin', '*') 91 res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') 92 res.setHeader('Access-Control-Allow-Headers', 'Content-Type') 93 94 if (req.method === 'OPTIONS') { 95 res.writeHead(204) 96 res.end() 97 return 98 } 99 100 // Health check 101 if (url.pathname === '/' || url.pathname === '/health') { 102 res.writeHead(200, { 'Content-Type': 'application/json' }) 103 res.end(JSON.stringify({ status: 'ok', identifier })) 104 return 105 } 106 107 // Publish message 108 if (url.pathname === '/publish' && req.method === 'POST') { 109 const body = await parseBody(req) 110 const payload = { 111 type: 'message', 112 source: 'phone-curl', 113 timestamp: new Date().toISOString(), 114 content: body.trim() 115 } 116 117 console.log(`[relay] Publishing message: ${body.slice(0, 50)}...`) 118 const success = await publishToNostr(payload) 119 120 res.writeHead(200, { 'Content-Type': 'application/json' }) 121 res.end(JSON.stringify({ ok: true, relays: success, payload })) 122 return 123 } 124 125 // Publish structured context 126 if (url.pathname === '/context' && req.method === 'POST') { 127 const body = await parseBody(req) 128 let data 129 try { 130 data = JSON.parse(body) 131 } catch { 132 data = { raw: body.trim() } 133 } 134 135 const payload = { 136 type: 'context', 137 source: 'phone-curl', 138 timestamp: new Date().toISOString(), 139 ...data 140 } 141 142 console.log(`[relay] Publishing context: ${JSON.stringify(data).slice(0, 50)}...`) 143 const success = await publishToNostr(payload) 144 145 res.writeHead(200, { 'Content-Type': 'application/json' }) 146 res.end(JSON.stringify({ ok: true, relays: success, payload })) 147 return 148 } 149 150 // Publish phoenix state 151 if (url.pathname === '/phoenix' && req.method === 'POST') { 152 const body = await parseBody(req) 153 let data 154 try { 155 data = JSON.parse(body) 156 } catch { 157 data = { checkpoint: body.trim() } 158 } 159 160 const payload = { 161 type: 'phoenix', 162 source: 'phone-curl', 163 timestamp: new Date().toISOString(), 164 ...data 165 } 166 167 console.log(`[relay] Publishing phoenix state`) 168 const success = await publishToNostr(payload) 169 170 res.writeHead(200, { 'Content-Type': 'application/json' }) 171 res.end(JSON.stringify({ ok: true, relays: success })) 172 return 173 } 174 175 // 404 176 res.writeHead(404, { 'Content-Type': 'application/json' }) 177 res.end(JSON.stringify({ error: 'Not found', endpoints: ['/publish', '/context', '/phoenix'] })) 178 } 179 180 async function main() { 181 const args = process.argv.slice(2) 182 let port = 7777 183 184 for (let i = 0; i < args.length; i++) { 185 if (args[i] === '--port' || args[i] === '-p') { 186 port = parseInt(args[++i]) 187 } 188 } 189 190 loadKeys() 191 192 const server = http.createServer(handleRequest) 193 server.listen(port, '0.0.0.0', () => { 194 console.log('╔══════════════════════════════════════════════════════════╗') 195 console.log('║ PHONE RELAY SERVER ║') 196 console.log('║ curl → Nostr → Hyperswarm ║') 197 console.log('╚══════════════════════════════════════════════════════════╝') 198 console.log() 199 console.log(`[relay] Listening on http://0.0.0.0:${port}`) 200 console.log(`[relay] Sovereign ID: ${identifier}`) 201 console.log() 202 console.log('Endpoints:') 203 console.log(` POST /publish - Send plain text message`) 204 console.log(` POST /context - Send JSON context`) 205 console.log(` POST /phoenix - Send phoenix state`) 206 console.log() 207 console.log('From phone:') 208 console.log(` curl -X POST http://YOUR_IP:${port}/publish -d "message"`) 209 console.log() 210 console.log('────────────────────────────────────────────────────────────') 211 }) 212 } 213 214 main().catch(console.error)