#!/usr/bin/env node /** * transfer-content.mjs — re-publish one key's addressable spots/shops under * another key ("ownership transfer"). * * Why a script and not a relay move: addressable events (kind 30818 spots, * 30017 shops) are SIGNED BY THEIR AUTHOR. To move them you must re-sign each * event with the destination key and publish it, and (optionally) publish a * NIP-09 delete for the original signed by the source key. That needs BOTH * private keys. * * Usage: * # 1. Preview what would move (read-only, no keys needed): * node scripts/transfer-content.mjs \ * --from npub1... --to npub1... --dry-run * * # 2. Do it (keys via env — NEVER commit or paste them in chat): * TO_NSEC=nsec1... node scripts/transfer-content.mjs \ * --from npub1... --to npub1... --yes * * # 3. Optionally delete the originals too (needs the SOURCE key): * TO_NSEC=nsec1... FROM_NSEC=nsec1... node scripts/transfer-content.mjs \ * --from npub1... --to npub1... --delete-originals --yes * * Safety: * - `--yes` is required to publish anything; without it this is a dry run. * - Re-signed events keep the same kind/content/tags/d, with a fresh * created_at. A `client` tag on the original is preserved. * - The source events are left untouched unless --delete-originals. */ import { SimplePool, nip19, finalizeEvent } from 'nostr-tools'; const SPOT = 30818; const SHOP = 30017; const DEFAULT_RELAYS = [ 'wss://goskatespots.nostr1.com', 'wss://relay.damus.io', 'wss://nos.lol', ]; function parseArgs(argv) { const out = { relays: DEFAULT_RELAYS }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--from') out.from = argv[++i]; else if (a === '--to') out.to = argv[++i]; else if (a === '--relays') out.relays = argv[++i].split(',').map((s) => s.trim()).filter(Boolean); else if (a === '--delete-originals') out.deleteOriginals = true; else if (a === '--yes') out.yes = true; else if (a === '--dry-run') out.dryRun = true; } out.dryRun = out.dryRun || !out.yes; // publish requires an explicit --yes return out; } function toHex(id) { if (/^[0-9a-f]{64}$/i.test(id)) return id.toLowerCase(); const d = nip19.decode(id); if (d.type !== 'npub' && d.type !== 'nprofile') throw new Error(`Not an npub: ${id}`); return d.data.pubkey ?? d.data; } function skFromEnv(name) { const v = process.env[name]; if (!v) return null; const d = nip19.decode(v.trim()); if (d.type !== 'nsec') throw new Error(`${name} is not an nsec`); return d.data; } const tag = (ev, name) => ev.tags.find((t) => t[0] === name)?.[1]; async function main() { const args = parseArgs(process.argv.slice(2)); if (!args.from || !args.to) { console.error('Usage: node scripts/transfer-content.mjs --from --to [--delete-originals] [--yes]'); process.exit(1); } const fromHex = toHex(args.from); const toHexId = toHex(args.to); if (fromHex === toHexId) throw new Error('--from and --to are the same key.'); const pool = new SimplePool(); let events = []; try { events = await pool.querySync(args.relays, { authors: [fromHex], kinds: [SPOT, SHOP] }); } finally { // keep the pool open for publishing below } const spots = events.filter((e) => e.kind === SPOT); const shops = events.filter((e) => e.kind === SHOP); console.log(`Source ${args.from.slice(0, 16)}… → destination ${args.to.slice(0, 16)}…`); console.log(`Found ${spots.length} spot(s) + ${shops.length} shop(s) on ${args.relays.length} relays.`); for (const e of [...spots, ...shops]) { const d = tag(e, 'd') ?? '?'; const label = tag(e, 'title') ?? tag(e, 'name') ?? ''; console.log(` • kind ${e.kind} d=${d} ${label}`); } const toSk = skFromEnv('TO_NSEC'); const fromSk = skFromEnv('FROM_NSEC'); if (args.dryRun) { console.log('\nDRY RUN — nothing published. Re-run with --yes (and TO_NSEC / FROM_NSEC) to apply.'); pool.close(args.relays); return; } if (!toSk) throw new Error('Set TO_NSEC (the destination key) to publish, or use --dry-run.'); // 1) Re-sign each event with the destination key. let ok = 0; for (const e of [...spots, ...shops]) { const unsigned = { kind: e.kind, created_at: Math.floor(Date.now() / 1000), tags: e.tags, content: e.content, }; const signed = finalizeEvent(unsigned, toSk); const results = await Promise.allSettled(pool.publish(args.relays, signed)); const accepted = results.filter((r) => r.status === 'fulfilled').length; if (accepted > 0) ok++; console.log(` republished kind ${e.kind} d=${tag(e, 'd')} → ${accepted}/${args.relays.length} relays (${signed.id.slice(0, 8)}…)`); } console.log(`\nRe-published ${ok}/${spots.length + shops.length} events as the destination key.`); // 2) Optionally delete the originals (NIP-09) with the source key. if (args.deleteOriginals) { if (!fromSk) throw new Error('--delete-originals needs FROM_NSEC (the source key).'); let deleted = 0; for (const e of [...spots, ...shops]) { const unsigned = { kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [ ['e', e.id], ['a', `${e.kind}:${fromHex}:${tag(e, 'd') ?? ''}`], ], content: 'Transferred to another key', }; const signed = finalizeEvent(unsigned, fromSk); const results = await Promise.allSettled(pool.publish(args.relays, signed)); if (results.some((r) => r.status === 'fulfilled')) deleted++; } console.log(`Published ${deleted} NIP-09 delete(s) for the originals.`); } else { console.log('Originals left in place. Pass --delete-originals (with FROM_NSEC) to remove them.'); } pool.close(args.relays); } main().catch((err) => { console.error('\n✖', err instanceof Error ? err.message : err); process.exit(1); });