SkateSpots/scripts/transfer-content.mjs
SkateSpots 6407310213 feat(relay): cut over community relay to self-hosted Newlay
Point SKATE_RELAY, the default relay pools, the DM/CVM/zap relay arrays, the cvm-server + push-service defaults, and the archive default at wss://relay.skate-spots.org, keeping the legacy hosted relay as a fallback during the client-update window. Restored 115/115 archived events; the relay runs behind Caddy with TLS. Docs + AGENTS.md updated.
2026-10-12 12:38:13 +07:00

159 lines
5.8 KiB
JavaScript

#!/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://relay.skate-spots.org',
'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);
});