/ keet-cli / nostr-bridge.js
nostr-bridge.js
  1  /**
  2   * Nostr Bridge for Keet CLI
  3   *
  4   * Publishes and receives encrypted room invites via Nostr.
  5   * Uses NIP-04 encrypted direct messages to share room keys.
  6   *
  7   * This allows you to:
  8   * 1. Create a room on desktop → auto-publish invite to Nostr
  9   * 2. Receive invites on any device with a Nostr client
 10   * 3. Join rooms across devices without manual key exchange
 11   */
 12  
 13  import {
 14    generateSecretKey,
 15    getPublicKey,
 16    finalizeEvent,
 17    nip04,
 18    nip19
 19  } from 'nostr-tools'
 20  import { Relay } from 'nostr-tools/relay'
 21  import crypto from 'crypto'
 22  import fs from 'fs'
 23  import path from 'path'
 24  import os from 'os'
 25  
 26  // Default relays
 27  const DEFAULT_RELAYS = [
 28    'wss://relay.damus.io',
 29    'wss://nos.lol',
 30    'wss://relay.nostr.band'
 31  ]
 32  
 33  // Storage for keys
 34  const CONFIG_DIR = path.join(os.homedir(), '.keet-cli')
 35  const KEYS_FILE = path.join(CONFIG_DIR, 'nostr-keys.json')
 36  
 37  export class NostrBridge {
 38    constructor(options = {}) {
 39      this.relays = options.relays || DEFAULT_RELAYS
 40      this.secretKey = null
 41      this.publicKey = null
 42      this.connections = []
 43      this.onInvite = options.onInvite || null
 44    }
 45  
 46    async init() {
 47      // Load or generate keys
 48      fs.mkdirSync(CONFIG_DIR, { recursive: true })
 49  
 50      if (fs.existsSync(KEYS_FILE)) {
 51        const data = JSON.parse(fs.readFileSync(KEYS_FILE, 'utf-8'))
 52        this.secretKey = Buffer.from(data.secretKey, 'hex')
 53        this.publicKey = data.publicKey
 54      } else {
 55        this.secretKey = generateSecretKey()
 56        this.publicKey = getPublicKey(this.secretKey)
 57  
 58        fs.writeFileSync(KEYS_FILE, JSON.stringify({
 59          secretKey: Buffer.from(this.secretKey).toString('hex'),
 60          publicKey: this.publicKey
 61        }, null, 2))
 62      }
 63  
 64      console.log(`[nostr] Public key: ${this.publicKey.slice(0, 16)}...`)
 65      console.log(`[nostr] npub: ${nip19.npubEncode(this.publicKey)}`)
 66  
 67      return this
 68    }
 69  
 70    async connect() {
 71      console.log('[nostr] Connecting to relays...')
 72  
 73      for (const url of this.relays) {
 74        try {
 75          const relay = await Relay.connect(url)
 76          console.log(`[nostr] Connected to ${url}`)
 77          this.connections.push(relay)
 78  
 79          // Subscribe to DMs for our pubkey
 80          relay.subscribe([
 81            {
 82              kinds: [4], // Encrypted DM
 83              '#p': [this.publicKey],
 84              since: Math.floor(Date.now() / 1000) - 3600 // Last hour
 85            }
 86          ], {
 87            onevent: (event) => this._handleEvent(event),
 88            oneose: () => console.log(`[nostr] Subscription synced on ${url}`)
 89          })
 90        } catch (e) {
 91          console.log(`[nostr] Failed to connect to ${url}: ${e.message}`)
 92        }
 93      }
 94  
 95      if (this.connections.length === 0) {
 96        throw new Error('Could not connect to any relays')
 97      }
 98  
 99      return this
100    }
101  
102    async _handleEvent(event) {
103      try {
104        // Decrypt the message
105        const decrypted = await nip04.decrypt(this.secretKey, event.pubkey, event.content)
106  
107        // Check if it's a room invite
108        if (decrypted.startsWith('keet-cli:room:')) {
109          const parts = decrypted.split(':')
110          const roomData = {
111            type: 'invite',
112            inviteCode: parts[2],
113            roomName: parts[3] || null,
114            from: event.pubkey.slice(0, 8),
115            timestamp: event.created_at
116          }
117  
118          console.log(`\r[nostr] Room invite received from ${roomData.from}`)
119          console.log(`  Invite: ${roomData.inviteCode.slice(0, 16)}...`)
120  
121          if (this.onInvite) {
122            this.onInvite(roomData)
123          }
124        } else {
125          console.log(`\r[nostr] Message from ${event.pubkey.slice(0, 8)}: ${decrypted.slice(0, 50)}...`)
126        }
127      } catch (e) {
128        // Ignore decryption errors (messages not for us)
129      }
130    }
131  
132    async publishRoomInvite(inviteCode, roomName = null, recipientPubkey = null) {
133      // Format: keet-cli:room:<invite>:<roomName>
134      const message = `keet-cli:room:${inviteCode}:${roomName || ''}`
135  
136      // If no recipient, send to self (for cross-device sync)
137      const recipient = recipientPubkey || this.publicKey
138  
139      // Encrypt the message
140      const encrypted = await nip04.encrypt(this.secretKey, recipient, message)
141  
142      // Create the event
143      const event = finalizeEvent({
144        kind: 4, // Encrypted DM
145        created_at: Math.floor(Date.now() / 1000),
146        tags: [['p', recipient]],
147        content: encrypted
148      }, this.secretKey)
149  
150      // Publish to all connected relays
151      let published = 0
152      for (const relay of this.connections) {
153        try {
154          await relay.publish(event)
155          published++
156        } catch (e) {
157          console.log(`[nostr] Failed to publish to relay: ${e.message}`)
158        }
159      }
160  
161      console.log(`[nostr] Room invite published to ${published} relay(s)`)
162      return event.id
163    }
164  
165    async publishContext(context, recipientPubkey = null) {
166      // Publish Phoenix context or other data
167      const recipient = recipientPubkey || this.publicKey
168      const encrypted = await nip04.encrypt(this.secretKey, recipient, JSON.stringify(context))
169  
170      const event = finalizeEvent({
171        kind: 4,
172        created_at: Math.floor(Date.now() / 1000),
173        tags: [['p', recipient]],
174        content: encrypted
175      }, this.secretKey)
176  
177      let published = 0
178      for (const relay of this.connections) {
179        try {
180          await relay.publish(event)
181          published++
182        } catch (e) {
183          // Ignore
184        }
185      }
186  
187      return published
188    }
189  
190    getPublicKey() {
191      return this.publicKey
192    }
193  
194    getNpub() {
195      return nip19.npubEncode(this.publicKey)
196    }
197  
198    async close() {
199      for (const relay of this.connections) {
200        try {
201          relay.close()
202        } catch (e) {
203          // Ignore
204        }
205      }
206      this.connections = []
207    }
208  }
209  
210  // CLI for standalone usage
211  async function main() {
212    const args = process.argv.slice(2)
213  
214    if (args[0] === '--help' || args[0] === '-h') {
215      console.log(`
216  Nostr Bridge for Keet CLI
217  
218  Usage:
219    node nostr-bridge.js                    Show your npub
220    node nostr-bridge.js --listen           Listen for room invites
221    node nostr-bridge.js --publish <invite> Publish a room invite
222    node nostr-bridge.js --context <json>   Publish context data
223  
224  Options:
225    --to <npub>    Recipient public key (default: self)
226    --relays <...> Comma-separated relay URLs
227  `)
228      process.exit(0)
229    }
230  
231    const bridge = new NostrBridge()
232    await bridge.init()
233  
234    if (args[0] === '--listen') {
235      bridge.onInvite = (invite) => {
236        console.log('')
237        console.log('To join this room:')
238        console.log(`  keet-cli ${invite.inviteCode}`)
239        console.log('')
240      }
241  
242      await bridge.connect()
243      console.log('')
244      console.log('Listening for room invites...')
245      console.log('Press Ctrl+C to stop.')
246  
247      // Keep alive
248      process.stdin.resume()
249  
250    } else if (args[0] === '--publish' && args[1]) {
251      await bridge.connect()
252      await bridge.publishRoomInvite(args[1], args[2] || null)
253      await bridge.close()
254  
255    } else if (args[0] === '--context' && args[1]) {
256      await bridge.connect()
257      const context = JSON.parse(args[1])
258      const count = await bridge.publishContext(context)
259      console.log(`Published to ${count} relays`)
260      await bridge.close()
261  
262    } else {
263      // Just show keys
264      console.log('')
265      console.log('Your Nostr identity:')
266      console.log(`  npub: ${bridge.getNpub()}`)
267      console.log(`  hex:  ${bridge.getPublicKey()}`)
268      console.log('')
269      console.log('Add this npub to your contacts to receive room invites.')
270      console.log('Use --listen to wait for invites, or --publish to share a room.')
271    }
272  }
273  
274  // Run if called directly
275  const isMain = process.argv[1]?.endsWith('nostr-bridge.js')
276  if (isMain) {
277    main().catch(err => {
278      console.error('Error:', err.message)
279      process.exit(1)
280    })
281  }