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.
This commit is contained in:
SkateSpots 2026-10-04 18:06:07 +07:00
parent 084c002c45
commit b91224699a
2 changed files with 166 additions and 0 deletions

View file

@ -1688,6 +1688,13 @@ Rules:
- Dry run (no upload): `SIGN_WITH='<signer>' zsp publish --offline zapstore.yaml`. - Dry run (no upload): `SIGN_WITH='<signer>' zsp publish --offline zapstore.yaml`.
### Relay archive (dataset backup) ### 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 - `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 community relay to gzipped JSONL of the raw signed events (restorable to any
relay, signatures intact). Default dir `~/.backups/relay` (override with relay, signatures intact). Default dir `~/.backups/relay` (override with

View file

@ -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 <npub> --to <npub> [--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);
});