publish-wrap-with-auth.ts
Nostr Agent Onboarding · start here · all examples · source:
nostr-dev/sdk/examples/publish-wrap-with-auth.ts· snapshot 2026-10-10
Publish a pre-signed event to a relay that demands NIP-42 AUTH, without re-signing it.
// Publish a fully-signed event (e.g. a NIP-59 gift wrap) to a relay that
// requires NIP-42 AUTH, WITHOUT letting any helper re-sign the event itself.
// `nak event --sec ... --auth` rebuilds the event when the --sec pubkey
// doesn't match event.pubkey, which corrupts gift wraps. This script keeps
// the wrap byte-for-byte and only uses the key to satisfy AUTH.
//
// Usage:
// NOSTR_SECRET_KEY=nsec1... npx tsx publish-wrap-with-auth.ts <event.json> <relay-url> [more relays]
import WebSocket from "ws";
import * as fs from "fs";
import { finalizeEvent } from "nostr-tools/pure";
import { nip19 } from "nostr-tools";
const eventPath = process.argv[2];
const relayUrls = process.argv.slice(3);
if (!eventPath || relayUrls.length === 0) {
console.error("usage: publish-wrap-with-auth.ts <event.json> <relay-url> [more relays]");
process.exit(2);
}
const nsec = process.env.NOSTR_SECRET_KEY;
if (!nsec) {
console.error("NOSTR_SECRET_KEY env var must be set (nsec1... or hex)");
process.exit(2);
}
const sk: Uint8Array = (() => {
if (nsec.startsWith("nsec1")) {
const { type, data } = nip19.decode(nsec);
if (type !== "nsec") throw new Error("not an nsec");
return data as Uint8Array;
}
// hex
const bytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) bytes[i] = parseInt(nsec.slice(i * 2, i * 2 + 2), 16);
return bytes;
})();
const evt = JSON.parse(fs.readFileSync(eventPath, "utf-8"));
async function publishToRelay(url: string): Promise<{ url: string; result: string }> {
return new Promise((resolve) => {
const ws = new WebSocket(url);
let publishedOnce = false;
const finish = (result: string) => {
try { ws.close(); } catch {}
resolve({ url, result });
};
const timeout = setTimeout(() => finish("TIMEOUT (15s)"), 15000);
let pendingChallenge: string | null = null;
let eventSent = false;
let authSent = false;
const trySendEvent = () => {
if (!eventSent) {
eventSent = true;
ws.send(JSON.stringify(["EVENT", evt]));
}
};
const respondToChallenge = (challenge: string) => {
if (authSent) return;
authSent = true;
const authEvent = finalizeEvent(
{
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", url],
["challenge", challenge],
],
content: "",
},
sk
);
ws.send(JSON.stringify(["AUTH", authEvent]));
};
ws.on("open", () => {
// Wait ~500 ms for a proactive AUTH challenge before sending EVENT,
// so AUTH-first relays don't reject us with auth-required.
setTimeout(() => {
if (pendingChallenge) respondToChallenge(pendingChallenge);
trySendEvent();
}, 500);
});
ws.on("message", (data) => {
let msg: any;
try { msg = JSON.parse(data.toString()); } catch { return; }
if (!Array.isArray(msg)) return;
console.error(`[${url}] <- ${JSON.stringify(msg).slice(0, 200)}`);
if (msg[0] === "AUTH") {
pendingChallenge = msg[1];
respondToChallenge(msg[1]);
// After AUTH, retry EVENT if we already sent once and got rejected.
if (eventSent) {
eventSent = false;
setTimeout(trySendEvent, 100);
}
} else if (msg[0] === "OK" && msg[1] === evt.id) {
const ok = msg[2];
const note = msg[3] ?? "";
if (!ok && /auth-required/i.test(note)) {
// Wait for the relay to send us an AUTH challenge if it hasn't already.
return;
}
clearTimeout(timeout);
finish(ok ? `success` : `rejected: ${note}`);
} else if (msg[0] === "NOTICE") {
console.error(`[${url}] NOTICE: ${msg[1]}`);
} else if (msg[0] === "CLOSED") {
clearTimeout(timeout);
finish(`CLOSED: ${msg[2] ?? ""}`);
}
});
ws.on("error", (err) => {
clearTimeout(timeout);
finish(`error: ${err.message}`);
});
});
}
(async () => {
console.error(`event id: ${evt.id}`);
console.error(`event kind: ${evt.kind}`);
console.error(`event pubkey: ${evt.pubkey} (preserved, NOT re-signed)`);
console.error("");
for (const url of relayUrls) {
const { result } = await publishToRelay(url);
console.log(`${url}\t${result}`);
}
})();