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:
parent
084c002c45
commit
b91224699a
2 changed files with 166 additions and 0 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
159
scripts/transfer-content.mjs
Normal file
159
scripts/transfer-content.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue