/** * Cordn join-request watcher. * * Coordinator join requests are RPC rows, not Nostr events. This small always-on * job turns each new request into: * 1. a NIP-17 DM for OS/web push (the app's push service marks `skatespots`), * 2. an encrypted NIP-78 notification for the in-app bell. * * It uses a dedicated watcher key (no admin key), only reads coordinator * metadata + public crew events, and never sees message plaintext. * * Run: npx tsx scripts/cordn-join-watcher.ts */ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import WebSocket from 'ws'; import { finalizeEvent, generateSecretKey, getPublicKey, nip19, } from 'nostr-tools'; import * as nip44 from 'nostr-tools/nip44'; import * as nip59 from 'nostr-tools/nip59'; import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool'; import { CordnCoordinatorClient } from '../src/lib/cordn/native/coordinator'; import type { FetchManyPendingJoinRequestsOutput } from '@cordn/core'; import { SKATESPOTS_COORDINATOR } from '../src/lib/coordinator'; import { CORDN_JOIN_D_PREFIX, CORDN_JOIN_TAG, CORDN_WATCHER_PUBKEY, } from '../src/lib/cordn/watch'; (globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket; useWebSocketImplementation(WebSocket as never); const COMMUNITY = 'wss://relay.skate-spots.org'; const DISCOVERY = [ 'wss://purplepag.es', 'wss://relay.ditto.pub', 'wss://relay.skate-spots.org', 'wss://nos.lol', ]; const CREW_KIND = 35006; const DM_KIND = 14; const GIFT_WRAP_KIND = 1059; const PUSH_MARKER = 'skatespots'; interface Crew { gid: string; name: string; pubkey: string; identifier: string; recipients: string[]; } interface WatcherState { seen: Record; } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); function readWatcherSecret(): Uint8Array { const file = join(homedir(), '.config/skatespots/cordn-watcher.env'); const text = readFileSync(file, 'utf8'); const nsec = text.match(/^CORDN_WATCHER_NSEC=(nsec1[0-9a-z]+)$/m)?.[1]; if (!nsec) throw new Error(`CORDN_WATCHER_NSEC not found in ${file}`); const decoded = nip19.decode(nsec); if (decoded.type !== 'nsec') throw new Error('CORDN_WATCHER_NSEC is not an nsec'); const pubkey = getPublicKey(decoded.data); if (pubkey !== CORDN_WATCHER_PUBKEY) { throw new Error('watcher key does not match src/lib/cordn/watch.ts'); } return decoded.data; } function statePath(): string { const dir = join(homedir(), '.local', 'state', 'skatespots-cordn-watcher'); mkdirSync(dir, { recursive: true }); return join(dir, 'state.json'); } function loadState(): WatcherState { const path = statePath(); if (!existsSync(path)) return { seen: {} }; try { const parsed = JSON.parse(readFileSync(path, 'utf8')) as Partial; return { seen: parsed.seen ?? {} }; } catch { return { seen: {} }; } } function saveState(state: WatcherState): void { const cutoff = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60; for (const [key, at] of Object.entries(state.seen)) { if (at < cutoff) delete state.seen[key]; } writeFileSync(statePath(), JSON.stringify(state, null, 2), { mode: 0o600 }); } function randomPastTimestamp(): number { return Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 24 * 60 * 60); } function withTimeout(promise: Promise, ms: number): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)), ]); } function shortNpub(pubkey: string): string { try { const npub = nip19.npubEncode(pubkey); return `${npub.slice(0, 12)}…${npub.slice(-6)}`; } catch { return pubkey.slice(0, 12); } } function crewFromEvent(event: { pubkey: string; tags: string[][]; }): Crew | null { const d = event.tags.find(([name]) => name === 'd')?.[1]; const name = event.tags.find(([name]) => name === 'title')?.[1]; if (!d || !name) return null; const coordinatorTag = event.tags.find(([name]) => name === 'coordinator'); const coordinatorPubkey = coordinatorTag?.[2]; if (coordinatorPubkey && coordinatorPubkey !== SKATESPOTS_COORDINATOR.pubkey) return null; const admins = event.tags .filter(([name, value, marker]) => name === 'p' && /^[0-9a-f]{64}$/i.test(value ?? '') && marker === 'admin') .map(([, value]) => value.toLowerCase()); const recipients = [...new Set([event.pubkey.toLowerCase(), ...admins])]; return { gid: `${CREW_KIND}:${event.pubkey}:${d}`, name, pubkey: event.pubkey, identifier: d, recipients, }; } async function discoverDmRelays(pool: SimplePool, recipients: string[]): Promise { const relays = new Set([COMMUNITY]); const [inboxes, relayLists] = await Promise.all([ pool .querySync( DISCOVERY, { kinds: [10050], authors: recipients, limit: recipients.length * 2 }, { maxWait: 4000 }, ) .catch(() => []), pool .querySync( DISCOVERY, { kinds: [10002], authors: recipients, limit: recipients.length * 2 }, { maxWait: 4000 }, ) .catch(() => []), ]); const haveInbox = new Set(); const addRelay = (url: string | undefined) => { if (!url) return; relays.add(url.replace(/\/+$/, '')); }; for (const event of inboxes) { for (const [name, url] of event.tags) { if (name === 'relay' && url) { addRelay(url); haveInbox.add(event.pubkey); } } } for (const event of relayLists) { if (haveInbox.has(event.pubkey)) continue; for (const [name, url, marker] of event.tags) { if (name === 'r' && url && (!marker || marker === 'write')) addRelay(url); } } return [...relays]; } function createDmWrap(params: { watcherSecret: Uint8Array; recipient: string; text: string; }): ReturnType { const rumor = nip59.createRumor( { kind: DM_KIND, created_at: Math.floor(Date.now() / 1000), tags: [['p', params.recipient], ['subject', 'Cordn join request']], content: params.text, }, params.watcherSecret, ); const seal = nip59.createSeal(rumor, params.watcherSecret, params.recipient); const ephemeral = generateSecretKey(); const conversationKey = nip44.v2.utils.getConversationKey(ephemeral, params.recipient); const content = nip44.v2.encrypt(JSON.stringify(seal), conversationKey); return finalizeEvent( { kind: GIFT_WRAP_KIND, created_at: randomPastTimestamp(), tags: [['p', params.recipient], [PUSH_MARKER, 'push']], content, }, ephemeral, ); } function encryptedNotice(params: { watcherSecret: Uint8Array; recipient: string; key: string; payload: Record; }): ReturnType { const conversationKey = nip44.v2.utils.getConversationKey( params.watcherSecret, params.recipient, ); const content = nip44.v2.encrypt(JSON.stringify(params.payload), conversationKey); const d = `${CORDN_JOIN_D_PREFIX}:${createHash('sha256') .update(`${params.key}:${params.recipient}`) .digest('hex') .slice(0, 32)}`; return finalizeEvent( { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [ ['d', d], ['p', params.recipient], ['t', CORDN_JOIN_TAG], ['alt', 'Cordn join request'], ], content, }, params.watcherSecret, ); } async function main(): Promise { const watcherSecret = readWatcherSecret(); const watcherPubkey = getPublicKey(watcherSecret); const pool = new SimplePool(); const crewEvents = await pool.querySync( [COMMUNITY], { kinds: [CREW_KIND], '#t': ['skatecrew'], limit: 500 }, { maxWait: 8000 }, ); const crews = crewEvents .map(crewFromEvent) .filter((crew): crew is Crew => !!crew && crew.recipients.length > 0); if (crews.length === 0) { pool.close([COMMUNITY]); console.log('[cordn-watcher] no crews found'); return; } const coordinator = new CordnCoordinatorClient({ serverPubkey: SKATESPOTS_COORDINATOR.pubkey, relays: SKATESPOTS_COORDINATOR.relays, privateKeyHex: Array.from(watcherSecret, (byte) => byte.toString(16).padStart(2, '0')).join(''), }); let requests: FetchManyPendingJoinRequestsOutput; try { requests = await coordinator.fetchManyPendingJoinRequests({ groups: crews.map((crew) => ({ gid: crew.gid })), }); } finally { await coordinator.disconnect().catch(() => undefined); } if (requests.requests.length === 0) { pool.close([COMMUNITY]); console.log('[cordn-watcher] no pending join requests'); return; } const byGid = new Map(crews.map((crew) => [crew.gid, crew])); const state = loadState(); let sent = 0; for (const request of requests.requests) { const crew = byGid.get(request.gid); if (!crew) continue; const key = `${request.gid}:${request.pk}:${request.kp_ref}`; if (state.seen[key]) continue; const atSeconds = request.at > 1e12 ? Math.floor(request.at / 1000) : request.at; const payload = { id: key, gid: request.gid, crewName: crew.name, crewPubkey: crew.pubkey, crewIdentifier: crew.identifier, requester: request.pk, at: atSeconds, }; const text = [ `New private-chat request for ${crew.name}.`, '', `Requester: ${shortNpub(request.pk)}`, '', `Review: https://skate-spots.org/#/crew/${crew.pubkey}/${crew.identifier}?tab=mls`, ].join('\n'); const relays = await discoverDmRelays(pool, crew.recipients); let delivered = 0; for (const recipient of crew.recipients) { const notice = encryptedNotice({ watcherSecret, recipient, key, payload, }); const wrap = createDmWrap({ watcherSecret, recipient, text }); const published = await Promise.allSettled([ ...pool.publish([COMMUNITY], notice).map((promise) => withTimeout(promise, 10_000)), ...pool.publish(relays, wrap).map((promise) => withTimeout(promise, 10_000)), ]); if (published.some((result) => result.status === 'fulfilled')) delivered += 1; } if (delivered > 0) { sent += delivered; state.seen[key] = atSeconds || Math.floor(Date.now() / 1000); saveState(state); } } pool.close([...new Set([COMMUNITY, ...DISCOVERY])]); console.log( JSON.stringify( { watcher: nip19.npubEncode(watcherPubkey), crews: crews.length, pending: requests.requests.length, newRequests: sent, }, null, 2, ), ); // Keep the timer's journal quiet on a normal idle run. await sleep(10); } main().catch((error) => { console.error( `[cordn-watcher] ${error instanceof Error ? error.message : String(error)}`, ); process.exit(1); });