From b91224699afd756123bd271e630c90a1a5169d6a Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 4 Oct 2026 18:06:07 +0700 Subject: [PATCH] Add scripts/transfer-content.mjs (move spots/shops between keys) Addressable events (30818/30017) are author-signed, so "transferring" them means re-signing each under the destination key + publishing, and optionally publishing NIP-09 deletes for the originals. This script does that; it needs TO_NSEC (destination) and, only for --delete-originals, FROM_NSEC. --dry-run (default) needs no keys and just lists what would move. --- AGENTS.md | 7 ++ scripts/transfer-content.mjs | 159 +++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 scripts/transfer-content.mjs diff --git a/AGENTS.md b/AGENTS.md index ad42bb2..599e1d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1688,6 +1688,13 @@ Rules: - Dry run (no upload): `SIGN_WITH='' zsp publish --offline zapstore.yaml`. ### Relay archive (dataset backup) +- **Transfer content between keys (`scripts/transfer-content.mjs`, 2026-10-04):** + re-signs one key's addressable **spots (30818) + shops (30017)** under another + key and publishes (optionally NIP-09-deleting the originals). Addressable events + are author-signed, so a "transfer" needs **both** keys — `TO_NSEC` for the new + events, `FROM_NSEC` only with `--delete-originals`. `--dry-run` (default) needs + no keys. ⚠️ A claimed/derived event can't be moved without the author's key; + the app's `/my data` can delete originals via the logged-in (Amber) signer. - `scripts/archive-relay.mjs` snapshots **the app's own content** off the community relay to gzipped JSONL of the raw signed events (restorable to any relay, signatures intact). Default dir `~/.backups/relay` (override with diff --git a/scripts/transfer-content.mjs b/scripts/transfer-content.mjs new file mode 100644 index 0000000..837e6f1 --- /dev/null +++ b/scripts/transfer-content.mjs @@ -0,0 +1,159 @@ +#!/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); +});