#!/usr/bin/env node /** * Send a plain-text SkateSpots ops report to the two admins as a NIP-17 DM. * * The message is read from stdin. The sender is the app identity in * ~/.config/skatespots/zsp.env (mode 600); the nsec is never printed. * * Recipients come from src/lib/admins.ts (the single source of truth): * npub1yrnuj… (founder) and npub1e2ay… (second admin). * * Delivery mirrors the app's DM logic: discover each admin's kind-10050 inbox, * fall back to their NIP-65 write relays, and always include the community relay * (the push service watches it). One gift wrap is published per recipient. */ import { readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { SimplePool, useWebSocketImplementation } from 'nostr-tools/pool'; import * as nip44 from 'nostr-tools/nip44'; import * as nip59 from 'nostr-tools/nip59'; import * as nip19 from 'nostr-tools/nip19'; import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools/pure'; import WebSocket from 'ws'; import { ADMIN_PUBKEYS } from '../src/lib/admins'; useWebSocketImplementation(WebSocket as never); const DISCOVERY = [ 'wss://purplepag.es', 'wss://relay.ditto.pub', 'wss://relay.skate-spots.org', 'wss://nos.lol', ]; const COMMUNITY = 'wss://relay.skate-spots.org'; function readSecret(): string { const fromEnv = process.env.SIGN_WITH?.trim(); if (fromEnv) return fromEnv; const file = process.env.SKATES_RELEASE_ENV || join(homedir(), '.config/skatespots/zsp.env'); const text = readFileSync(file, 'utf8'); const match = text.match(/^\s*(?:export\s+)?SIGN_WITH=(nsec1[0-9a-z]+)\s*$/m); if (!match) throw new Error(`SIGN_WITH not found in ${file}`); return match[1]; } function withTimeout(p: Promise, ms: number): Promise { return Promise.race([ p, new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms)), ]); } const DM_KIND = 14; const GIFT_WRAP_KIND = 1059; /** Same plaintext marker the app's DM sender and push-service use. */ const PUSH_MARKER = 'skatespots'; function randomPastTimestamp(): number { return Math.floor(Date.now() / 1000) - Math.floor(Math.random() * 2 * 24 * 60 * 60); } /** * NIP-17 gift wraps, mirroring `useDirectMessages.ts` but adding the push * marker to every admin copy (including the sender's, so the founder's phone * also wakes for the daily report). The push service only reads the recipient * `p` tag + marker; it never decrypts. */ function createWraps( senderSecret: Uint8Array, senderPubkey: string, admins: string[], text: string, ) { const rumor = nip59.createRumor( { kind: DM_KIND, created_at: Math.floor(Date.now() / 1000), tags: [...admins.map((pubkey) => ['p', pubkey]), ['subject', 'SkateSpots ops']], content: text, }, senderSecret, ); const targets = [...new Set([senderPubkey, ...admins])]; return targets.map((target) => { const seal = nip59.createSeal(rumor, senderSecret, target); const ephemeral = generateSecretKey(); const conversationKey = nip44.v2.utils.getConversationKey(ephemeral, target); const content = nip44.v2.encrypt(JSON.stringify(seal), conversationKey); return finalizeEvent( { kind: GIFT_WRAP_KIND, created_at: randomPastTimestamp(), tags: [['p', target], [PUSH_MARKER, 'push']], content, }, ephemeral, ); }); } async function main(): Promise { const text = readFileSync(0, 'utf8').trim(); if (!text) throw new Error('empty report on stdin'); const secret = readSecret(); const decoded = nip19.decode(secret); if (decoded.type !== 'nsec') throw new Error('SIGN_WITH is not an nsec'); const senderSecret = decoded.data; const senderPubkey = getPublicKey(senderSecret); const pool = new SimplePool(); const authors = [...new Set([...ADMIN_PUBKEYS, senderPubkey])]; const [inboxes, relayLists] = await Promise.all([ pool.querySync( DISCOVERY, { kinds: [10050], authors, limit: authors.length * 2 }, { maxWait: 4000 }, ).catch(() => []), pool.querySync( DISCOVERY, { kinds: [10002], authors: ADMIN_PUBKEYS, limit: ADMIN_PUBKEYS.length }, { maxWait: 4000 }, ).catch(() => []), ]); const relays = new Set([COMMUNITY]); const haveInbox = new Set(); for (const event of inboxes) { const urls = event.tags .filter(([name]) => name === 'relay') .map(([, url]) => url) .filter(Boolean); if (urls.length) haveInbox.add(event.pubkey); for (const url of urls) relays.add(url); } // A recipient with no inbox list falls back to their NIP-65 write relays. 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')) relays.add(url); } } const wraps = createWraps(senderSecret, senderPubkey, ADMIN_PUBKEYS, text); if (process.env.OPS_DRY_RUN === '1') { console.log(`sender: ${nip19.npubEncode(senderPubkey)}`); console.log(`recipients: ${ADMIN_PUBKEYS.map((p) => nip19.npubEncode(p)).join(', ')}`); console.log(`relays: ${[...relays].join(', ')}`); console.log(`dry run: ${wraps.length} gift wrap(s) prepared, nothing published`); pool.close([...relays]); return; } let accepted = 0; for (const wrap of wraps) { const results = await Promise.allSettled( pool.publish([...relays], wrap).map((p) => withTimeout(p, 10000)), ); accepted += results.filter((result) => result.status === 'fulfilled').length; } pool.close([...relays]); console.log(`recipients: ${ADMIN_PUBKEYS.map((p) => nip19.npubEncode(p)).join(', ')}`); console.log(`relays: ${[...relays].join(', ')}`); console.log(`accepted: ${accepted} publish(es)`); console.log(`message ids: ${wraps.map((wrap) => wrap.id).join(', ')}`); if (accepted === 0) process.exitCode = 1; } main().catch((error) => { console.error(`send-ops-report: ${error instanceof Error ? error.message : String(error)}`); process.exit(1); });