/ keet-cli / phone-relay-server.js
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)