feat(nostr): NIP-17 private DMs and NIP-46 bunker authentication

Two major enhancements to the Nostr extension:

## NIP-17 Private Direct Messages

Adds NIP-17 with NIP-59 gift wrapping for privacy-preserving DMs.

Protocol comparison:
- NIP-04 (legacy): Kind 4, sender/timing visible, AES-CBC
- NIP-17 (new): Kind 1059→13→14, sender hidden, timestamps randomized ±48h, XChaCha20-Poly1305

New dmProtocol config option:
- "dual" (default): Accept both, send NIP-17
- "nip17": NIP-17 only (max privacy)
- "nip04": Legacy only

Changes:
- Subscribe to kind:4 (NIP-04) and kind:1059 (GiftWrap) events
- 48-hour lookback for NIP-17 randomized timestamps
- Unwrap gift wraps via nostr-tools/nip59

## NIP-46 Bunker Authentication

Users can connect their Nostr identity via remote signers (Amber, nsec.app).

Agent tools:
- nostr_connect: Link via bunker URL
- nostr_post: Kind 1 notes with NIP-10 threading
- nostr_react: Reactions (+/-/emoji) per NIP-25
- nostr_repost: Kind 6/16 reposts per NIP-18
- nostr_fetch: Query by author/hashtag/mentions/NIP-50 search
- nostr_article: Kind 30023 long-form content per NIP-23
- nostr_disconnect/nostr_status: Session management

Bunker features:
- Session persistence (client key saved to disk)
- NIP-65 relay discovery
- Auth URL flow for browser approval
- Multi-account via bunkerIndex

HTTP endpoints for web UI:
- GET/POST/DELETE /api/channels/nostr/:id/bunker/:idx

## Safety

- NIP-09 deletion excluded (no agent deletion capability)
- Private keys remain in signer app
- Explicit user connection required

NIPs: 01, 10, 17, 18, 23, 25, 44, 46, 50, 59, 65

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Ulrich 2026-01-28 12:27:15 -05:00
parent 3130c65e74
commit b46d0395a6
10 changed files with 3392 additions and 4 deletions

View File

@ -4,8 +4,10 @@ import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { nostrPlugin } from "./src/channel.js";
import { setNostrRuntime, getNostrRuntime } from "./src/runtime.js";
import { createNostrProfileHttpHandler } from "./src/nostr-profile-http.js";
import { createNostrBunkerHttpHandler } from "./src/nostr-bunker-http.js";
import { createNostrAgentTools } from "./src/agent-tools.js";
import { resolveNostrAccount } from "./src/types.js";
import type { NostrProfile } from "./src/config-schema.js";
import type { NostrProfile, BunkerAccountConfig } from "./src/config-schema.js";
const plugin = {
id: "nostr",
@ -63,6 +65,87 @@ const plugin = {
});
api.registerHttpHandler(httpHandler);
// Register HTTP handler for bunker management
const bunkerHttpHandler = createNostrBunkerHttpHandler({
getBunkerAccounts: (accountId: string) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
return account.bunkerAccounts;
},
updateBunkerAccount: async (
accountId: string,
bunkerIndex: number,
update: Partial<BunkerAccountConfig>
) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
// Update the specific bunker account
const bunkerAccounts = [...account.bunkerAccounts];
while (bunkerAccounts.length <= bunkerIndex) {
bunkerAccounts.push({ bunkerUrl: "" });
}
bunkerAccounts[bunkerIndex] = {
...bunkerAccounts[bunkerIndex],
...update,
};
// Write back to config
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
await runtime.config.writeConfigFile({
...cfg,
channels: {
...channels,
nostr: {
...nostrConfig,
bunkerAccounts,
},
},
});
},
clearConfigBunkerUrl: async (accountId: string, bunkerIndex: number) => {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig() as ClawdbotConfig;
const account = resolveNostrAccount({ cfg, accountId });
// Clear the specific bunker account
const bunkerAccounts = [...account.bunkerAccounts];
if (bunkerIndex < bunkerAccounts.length) {
bunkerAccounts[bunkerIndex] = {
...bunkerAccounts[bunkerIndex],
bunkerUrl: "",
userPubkey: undefined,
connectedAt: undefined,
};
}
// Write back to config
const channels = (cfg.channels ?? {}) as Record<string, unknown>;
const nostrConfig = (channels.nostr ?? {}) as Record<string, unknown>;
await runtime.config.writeConfigFile({
...cfg,
channels: {
...channels,
nostr: {
...nostrConfig,
bunkerAccounts,
},
},
});
},
log: api.logger,
});
api.registerHttpHandler(bunkerHttpHandler);
// Register agent tools for bunker operations
for (const tool of createNostrAgentTools()) {
api.registerTool(tool);
}
},
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,531 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { buildReplyTags } from "./bunker-actions.js";
// Mock bunker-store
const mockSigner = {
signEvent: vi.fn(),
};
const mockBunkerConnection = {
signer: mockSigner,
userPubkey: "user123pubkey456",
bunkerPubkey: "bunker123pubkey",
relays: ["wss://relay.example.com"],
userWriteRelays: ["wss://write.relay.com"],
userReadRelays: ["wss://read.relay.com"],
connectedAt: Date.now(),
accountId: "test-account",
bunkerIndex: 0,
};
vi.mock("./bunker-store.js", () => ({
getBunkerConnection: vi.fn(() => mockBunkerConnection),
getFirstBunkerConnection: vi.fn(() => mockBunkerConnection),
}));
// Mock runtime
vi.mock("./runtime.js", () => ({
getNostrRuntime: vi.fn(() => ({
config: {
loadConfig: vi.fn(() => ({
channels: {
nostr: {
relays: ["wss://config.relay.com"],
},
},
})),
},
})),
}));
// Mock types
vi.mock("./types.js", () => ({
resolveNostrAccount: vi.fn(() => ({
relays: ["wss://config.relay.com"],
})),
}));
// Mock nostr-tools/kinds
vi.mock("nostr-tools/kinds", () => ({
ShortTextNote: 1,
Reaction: 7,
Repost: 6,
GenericRepost: 16,
EventDeletion: 5,
LongFormArticle: 30023,
}));
describe("bunker-actions", () => {
describe("buildReplyTags", () => {
it("builds correct tags for direct reply (reply to root)", () => {
const tags = buildReplyTags({
replyToId: "event123",
replyToPubkey: "author123",
});
expect(tags).toEqual([
["e", "event123", "", "root"],
["p", "author123"],
]);
});
it("builds correct tags for deep reply (reply to non-root)", () => {
const tags = buildReplyTags({
replyToId: "reply456",
replyToPubkey: "replier456",
rootId: "root123",
rootPubkey: "rootAuthor123",
});
expect(tags).toEqual([
["e", "root123", "", "root"],
["e", "reply456", "", "reply"],
["p", "rootAuthor123"],
["p", "replier456"],
]);
});
it("includes relay hint when provided", () => {
const tags = buildReplyTags({
replyToId: "event123",
replyToPubkey: "author123",
relayHint: "wss://relay.example.com",
});
expect(tags).toEqual([
["e", "event123", "wss://relay.example.com", "root"],
["p", "author123"],
]);
});
it("includes additional mentions without duplicates", () => {
const tags = buildReplyTags({
replyToId: "event123",
replyToPubkey: "author123",
mentions: ["mention1", "mention2", "author123"], // author123 should be deduplicated
});
expect(tags).toEqual([
["e", "event123", "", "root"],
["p", "author123"],
["p", "mention1"],
["p", "mention2"],
// author123 not duplicated
]);
});
it("handles deep reply with same author for root and reply", () => {
const tags = buildReplyTags({
replyToId: "reply456",
replyToPubkey: "author123", // same as root
rootId: "root123",
rootPubkey: "author123",
});
expect(tags).toEqual([
["e", "root123", "", "root"],
["e", "reply456", "", "reply"],
["p", "author123"],
// No duplicate p tag
]);
});
it("handles rootId same as replyToId (no separate reply tag)", () => {
const tags = buildReplyTags({
replyToId: "event123",
replyToPubkey: "author123",
rootId: "event123", // same as replyToId
rootPubkey: "author123",
});
expect(tags).toEqual([
["e", "event123", "", "root"],
// No reply tag when rootId === replyToId
["p", "author123"],
]);
});
});
describe("postNote", () => {
let mockPool: { publish: ReturnType<typeof vi.fn>; querySync: ReturnType<typeof vi.fn> };
beforeEach(async () => {
vi.clearAllMocks();
mockPool = {
publish: vi.fn().mockReturnValue([Promise.resolve("ok")]),
querySync: vi.fn().mockResolvedValue([]),
};
mockSigner.signEvent.mockResolvedValue({
id: "signed-event-id",
pubkey: "user123pubkey456",
kind: 1,
content: "test content",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
});
afterEach(() => {
vi.clearAllMocks();
});
it("posts a simple note", async () => {
const { postNote } = await import("./bunker-actions.js");
const result = await postNote({
accountId: "test-account",
content: "Hello Nostr!",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.eventId).toBe("signed-event-id");
expect(result.content).toBe("Hello Nostr!");
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 1,
content: "Hello Nostr!",
tags: [],
})
);
});
it("posts a reply with NIP-10 tags", async () => {
const { postNote } = await import("./bunker-actions.js");
await postNote({
accountId: "test-account",
content: "This is a reply",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
replyTo: "target-event-id",
replyToPubkey: "target-author-pubkey",
});
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 1,
content: "This is a reply",
tags: expect.arrayContaining([
["e", "target-event-id", "", "root"],
["p", "target-author-pubkey"],
]),
})
);
});
it("throws when replyTo is set but replyToPubkey is missing", async () => {
const { postNote } = await import("./bunker-actions.js");
await expect(
postNote({
accountId: "test-account",
content: "Invalid reply",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
replyTo: "target-event-id",
// replyToPubkey missing
})
).rejects.toThrow("replyToPubkey is required when replyTo is set");
});
});
describe("postReaction", () => {
let mockPool: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockPool = {
publish: vi.fn().mockReturnValue([Promise.resolve("ok")]),
};
mockSigner.signEvent.mockResolvedValue({
id: "reaction-event-id",
pubkey: "user123pubkey456",
kind: 7,
content: "+",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
});
it("posts a like reaction", async () => {
const { postReaction } = await import("./bunker-actions.js");
const result = await postReaction({
accountId: "test-account",
eventId: "target-event-id",
eventPubkey: "target-author-pubkey",
reaction: "+",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.eventId).toBe("reaction-event-id");
expect(result.reaction).toBe("+");
expect(result.targetEventId).toBe("target-event-id");
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 7,
content: "+",
tags: [
["e", "target-event-id", ""],
["p", "target-author-pubkey"],
["k", "1"], // Default kind
],
})
);
});
it("includes k tag for non-note events", async () => {
const { postReaction } = await import("./bunker-actions.js");
await postReaction({
accountId: "test-account",
eventId: "article-event-id",
eventPubkey: "article-author-pubkey",
eventKind: 30023,
reaction: "🔥",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 7,
content: "🔥",
tags: expect.arrayContaining([["k", "30023"]]),
})
);
});
});
describe("postRepost", () => {
let mockPool: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockPool = {
publish: vi.fn().mockReturnValue([Promise.resolve("ok")]),
};
});
it("creates kind:6 repost for kind:1 notes", async () => {
mockSigner.signEvent.mockResolvedValue({
id: "repost-event-id",
pubkey: "user123pubkey456",
kind: 6,
content: "",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
const { postRepost } = await import("./bunker-actions.js");
const result = await postRepost({
accountId: "test-account",
eventId: "note-event-id",
eventPubkey: "note-author-pubkey",
eventKind: 1,
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.kind).toBe(6);
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 6,
tags: [
["e", "note-event-id", ""],
["p", "note-author-pubkey"],
],
})
);
});
it("creates kind:16 generic repost for non-note events", async () => {
mockSigner.signEvent.mockResolvedValue({
id: "generic-repost-event-id",
pubkey: "user123pubkey456",
kind: 16,
content: "",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
const { postRepost } = await import("./bunker-actions.js");
const result = await postRepost({
accountId: "test-account",
eventId: "article-event-id",
eventPubkey: "article-author-pubkey",
eventKind: 30023,
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.kind).toBe(16);
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 16,
tags: expect.arrayContaining([["k", "30023"]]),
})
);
});
});
describe("fetchEvents", () => {
let mockPool: { querySync: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockPool = {
querySync: vi.fn().mockResolvedValue([
{
id: "event1",
pubkey: "pubkey1",
kind: 1,
content: "Hello",
tags: [],
created_at: 1234567890,
sig: "sig1",
},
]),
};
});
it("fetches events with filter", async () => {
const { fetchEvents } = await import("./bunker-actions.js");
const result = await fetchEvents({
accountId: "test-account",
filter: { kinds: [1], limit: 10 },
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.events).toHaveLength(1);
expect(result.events[0].id).toBe("event1");
expect(mockPool.querySync).toHaveBeenCalledWith(
expect.any(Array),
{ kinds: [1], limit: 10 },
expect.any(Object)
);
});
it("uses explicit relays when provided", async () => {
const { fetchEvents } = await import("./bunker-actions.js");
await fetchEvents({
accountId: "test-account",
filter: { kinds: [1] },
pool: mockPool as unknown as import("nostr-tools").SimplePool,
relays: ["wss://explicit.relay.com"],
});
expect(mockPool.querySync).toHaveBeenCalledWith(
["wss://explicit.relay.com"],
expect.any(Object),
expect.any(Object)
);
});
});
describe("postArticle", () => {
let mockPool: { publish: ReturnType<typeof vi.fn> };
beforeEach(() => {
vi.clearAllMocks();
mockPool = {
publish: vi.fn().mockReturnValue([Promise.resolve("ok")]),
};
mockSigner.signEvent.mockResolvedValue({
id: "article-event-id",
pubkey: "user123pubkey456",
kind: 30023,
content: "# My Article",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
});
it("posts a published article (kind:30023)", async () => {
const { postArticle } = await import("./bunker-actions.js");
const result = await postArticle({
accountId: "test-account",
title: "My Article",
content: "# My Article\n\nContent here",
identifier: "my-article",
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.eventId).toBe("article-event-id");
expect(result.title).toBe("My Article");
expect(result.identifier).toBe("my-article");
expect(result.kind).toBe(30023);
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 30023,
content: "# My Article\n\nContent here",
tags: expect.arrayContaining([
["d", "my-article"],
["title", "My Article"],
]),
})
);
});
it("posts a draft article (kind:30024)", async () => {
mockSigner.signEvent.mockResolvedValue({
id: "draft-event-id",
pubkey: "user123pubkey456",
kind: 30024,
content: "Draft content",
tags: [],
created_at: Math.floor(Date.now() / 1000),
sig: "signature",
});
const { postArticle } = await import("./bunker-actions.js");
const result = await postArticle({
accountId: "test-account",
title: "Draft Article",
content: "Draft content",
identifier: "draft-article",
isDraft: true,
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(result.kind).toBe(30024);
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
kind: 30024,
})
);
});
it("includes optional metadata in tags", async () => {
const { postArticle } = await import("./bunker-actions.js");
await postArticle({
accountId: "test-account",
title: "Full Article",
content: "Content",
identifier: "full-article",
summary: "A summary",
image: "https://example.com/image.jpg",
hashtags: ["nostr", "test"],
pool: mockPool as unknown as import("nostr-tools").SimplePool,
});
expect(mockSigner.signEvent).toHaveBeenCalledWith(
expect.objectContaining({
tags: expect.arrayContaining([
["summary", "A summary"],
["image", "https://example.com/image.jpg"],
["t", "nostr"],
["t", "test"],
]),
})
);
});
});
});

View File

@ -0,0 +1,515 @@
import type { SimplePool, EventTemplate, Event, Filter } from "nostr-tools";
import {
ShortTextNote,
Reaction,
Repost,
GenericRepost,
LongFormArticle,
} from "nostr-tools/kinds";
import { getFirstBunkerConnection, getBunkerConnection, type BunkerConnection } from "./bunker-store.js";
import { getNostrRuntime } from "./runtime.js";
import { resolveNostrAccount } from "./types.js";
// Kind 30024 (draft long-form) is not exported from nostr-tools/kinds
const DraftLong = 30024;
// ============================================================================
// Result Types
// ============================================================================
export interface PostNoteResult {
eventId: string;
pubkey: string;
content: string;
publishedTo: string[];
failedRelays: Array<{ relay: string; error: string }>;
}
export interface PostReactionResult {
eventId: string;
pubkey: string;
reaction: string;
targetEventId: string;
publishedTo: string[];
failedRelays: Array<{ relay: string; error: string }>;
}
export interface PostRepostResult {
eventId: string;
pubkey: string;
repostedEventId: string;
kind: number;
publishedTo: string[];
failedRelays: Array<{ relay: string; error: string }>;
}
export interface FetchEventsResult {
events: Event[];
relaysQueried: string[];
}
export interface PostArticleResult {
eventId: string;
pubkey: string;
title: string;
identifier: string;
kind: number;
publishedTo: string[];
failedRelays: Array<{ relay: string; error: string }>;
}
// ============================================================================
// Constants
// ============================================================================
/** Relay publish timeout in ms */
const RELAY_PUBLISH_TIMEOUT_MS = 5000;
/** Timeout for fetching events (ms) */
const RELAY_FETCH_TIMEOUT_MS = 5000;
// ============================================================================
// Helper Functions (must be defined before they're used)
// ============================================================================
/**
* Get relays from the Nostr channel configuration.
* Returns empty array if runtime not available.
*/
function getConfiguredRelays(): string[] {
try {
const runtime = getNostrRuntime();
const cfg = runtime.config.loadConfig();
const account = resolveNostrAccount({ cfg });
return account.relays;
} catch {
return [];
}
}
/**
* Get target relays from explicit opts or connection + config.
*/
function getTargetRelays(
explicitRelays: string[] | undefined,
conn: BunkerConnection
): string[] {
if (explicitRelays && explicitRelays.length > 0) {
return explicitRelays;
}
const configRelays = getConfiguredRelays();
const combined = new Set([
...conn.relays,
...conn.userWriteRelays,
...configRelays,
]);
return Array.from(combined);
}
/**
* Get target relays for reading (fetching events).
*/
function getReadRelays(
explicitRelays: string[] | undefined,
conn: BunkerConnection | null
): string[] {
if (explicitRelays && explicitRelays.length > 0) {
return explicitRelays;
}
const configRelays = getConfiguredRelays();
if (conn) {
const combined = new Set([
...conn.relays,
...conn.userReadRelays,
...configRelays,
]);
return Array.from(combined);
}
return configRelays;
}
/**
* Publish a signed event to multiple relays.
*/
async function publishToRelays(
pool: SimplePool,
signed: Event,
targetRelays: string[]
): Promise<{
publishedTo: string[];
failedRelays: Array<{ relay: string; error: string }>;
}> {
const publishedTo: string[] = [];
const failedRelays: Array<{ relay: string; error: string }> = [];
const publishPromises = targetRelays.map(async (relay) => {
try {
const publishPromise = pool.publish([relay], signed)[0];
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("timeout")), RELAY_PUBLISH_TIMEOUT_MS);
});
await Promise.race([publishPromise, timeoutPromise]);
publishedTo.push(relay);
} catch (err) {
failedRelays.push({
relay,
error: err instanceof Error ? err.message : String(err),
});
}
});
await Promise.all(publishPromises);
return { publishedTo, failedRelays };
}
/**
* Build NIP-10 compliant reply tags for threading.
* @see https://github.com/nostr-protocol/nips/blob/master/10.md
*/
export function buildReplyTags(opts: {
replyToId: string;
replyToPubkey: string;
rootId?: string;
rootPubkey?: string;
mentions?: string[];
relayHint?: string;
}): string[][] {
const tags: string[][] = [];
const relay = opts.relayHint ?? "";
// Determine root event
const rootEventId = opts.rootId ?? opts.replyToId;
const rootPubkey = opts.rootPubkey ?? opts.replyToPubkey;
// Root tag (e tag with "root" marker)
tags.push(["e", rootEventId, relay, "root"]);
// Reply tag (e tag with "reply" marker) - only if different from root
if (opts.rootId && opts.rootId !== opts.replyToId) {
tags.push(["e", opts.replyToId, relay, "reply"]);
}
// P tags for threading
tags.push(["p", rootPubkey]);
if (opts.replyToPubkey !== rootPubkey) {
tags.push(["p", opts.replyToPubkey]);
}
// Additional mentions
for (const pubkey of opts.mentions ?? []) {
if (pubkey !== rootPubkey && pubkey !== opts.replyToPubkey) {
tags.push(["p", pubkey]);
}
}
return tags;
}
// ============================================================================
// Action Functions
// ============================================================================
export interface PostNoteOpts {
accountId: string;
bunkerIndex?: number;
content: string;
pool: SimplePool;
relays?: string[]; // Override relays (defaults to bunker relays)
tags?: string[][]; // Optional tags (mentions, hashtags, etc.)
// NIP-10 reply threading
replyTo?: string; // Event ID to reply to
replyToPubkey?: string; // Pubkey of reply target (required if replyTo set)
rootEvent?: string; // Root of thread (if different from replyTo)
rootPubkey?: string; // Pubkey of root event author
mentions?: string[]; // Additional pubkeys to mention
}
/**
* Post a short text note (kind:1).
* Supports NIP-10 reply threading when replyTo is set.
*/
export async function postNote(opts: PostNoteOpts): Promise<PostNoteResult> {
const conn = opts.bunkerIndex !== undefined
? getBunkerConnection(opts.accountId, opts.bunkerIndex)
: getFirstBunkerConnection(opts.accountId);
if (!conn) {
throw new Error("No bunker connected. Use nostr_connect first.");
}
// Build tags - start with explicit tags or empty array
let tags = opts.tags ? [...opts.tags] : [];
// Add NIP-10 reply tags if this is a reply
if (opts.replyTo) {
if (!opts.replyToPubkey) {
throw new Error("replyToPubkey is required when replyTo is set");
}
const replyTags = buildReplyTags({
replyToId: opts.replyTo,
replyToPubkey: opts.replyToPubkey,
rootId: opts.rootEvent,
rootPubkey: opts.rootPubkey,
mentions: opts.mentions,
});
tags = [...tags, ...replyTags];
}
const event: EventTemplate = {
kind: ShortTextNote,
content: opts.content,
tags,
created_at: Math.floor(Date.now() / 1000),
};
const signed = await conn.signer.signEvent(event);
// Determine target relays
const targetRelays = getTargetRelays(opts.relays, conn);
const { publishedTo, failedRelays } = await publishToRelays(
opts.pool,
signed,
targetRelays
);
return {
eventId: signed.id,
pubkey: signed.pubkey,
content: opts.content,
publishedTo,
failedRelays,
};
}
export interface PostReactionOpts {
accountId: string;
bunkerIndex?: number;
eventId: string;
eventPubkey: string;
eventKind?: number;
reaction: string; // "+" for like, "-" for dislike, or emoji
relayHint?: string;
pool: SimplePool;
relays?: string[];
}
/**
* Post a reaction (kind:7) to an event.
* @see NIP-25 https://github.com/nostr-protocol/nips/blob/master/25.md
*/
export async function postReaction(opts: PostReactionOpts): Promise<PostReactionResult> {
const conn = opts.bunkerIndex !== undefined
? getBunkerConnection(opts.accountId, opts.bunkerIndex)
: getFirstBunkerConnection(opts.accountId);
if (!conn) {
throw new Error("No bunker connected. Use nostr_connect first.");
}
const eventKind = opts.eventKind ?? ShortTextNote;
// Build reaction event template (NIP-25 tag structure)
const event: EventTemplate = {
kind: Reaction, // 7
content: opts.reaction,
tags: [
["e", opts.eventId, opts.relayHint ?? ""],
["p", opts.eventPubkey],
["k", String(eventKind)],
],
created_at: Math.floor(Date.now() / 1000),
};
const signed = await conn.signer.signEvent(event);
// Determine target relays
const targetRelays = getTargetRelays(opts.relays, conn);
const { publishedTo, failedRelays } = await publishToRelays(
opts.pool,
signed,
targetRelays
);
return {
eventId: signed.id,
pubkey: signed.pubkey,
reaction: opts.reaction,
targetEventId: opts.eventId,
publishedTo,
failedRelays,
};
}
export interface PostRepostOpts {
accountId: string;
bunkerIndex?: number;
eventId: string;
eventPubkey: string;
eventKind?: number;
eventContent?: string; // JSON of original event (optional)
relayHint?: string;
pool: SimplePool;
relays?: string[];
}
/**
* Post a repost (kind:6 for notes, kind:16 for other kinds).
* @see NIP-18 https://github.com/nostr-protocol/nips/blob/master/18.md
*/
export async function postRepost(opts: PostRepostOpts): Promise<PostRepostResult> {
const conn = opts.bunkerIndex !== undefined
? getBunkerConnection(opts.accountId, opts.bunkerIndex)
: getFirstBunkerConnection(opts.accountId);
if (!conn) {
throw new Error("No bunker connected. Use nostr_connect first.");
}
const eventKind = opts.eventKind ?? ShortTextNote;
// Use kind 6 for kind 1 notes, kind 16 for others
const repostKind = eventKind === ShortTextNote ? Repost : GenericRepost;
const tags: string[][] = [
["e", opts.eventId, opts.relayHint ?? ""],
["p", opts.eventPubkey],
];
// For generic reposts (non-kind-1), include the original kind
if (repostKind === GenericRepost) {
tags.push(["k", String(eventKind)]);
}
const event: EventTemplate = {
kind: repostKind,
content: opts.eventContent ?? "", // Can include JSON of original event
tags,
created_at: Math.floor(Date.now() / 1000),
};
const signed = await conn.signer.signEvent(event);
// Determine target relays
const targetRelays = getTargetRelays(opts.relays, conn);
const { publishedTo, failedRelays } = await publishToRelays(
opts.pool,
signed,
targetRelays
);
return {
eventId: signed.id,
pubkey: signed.pubkey,
repostedEventId: opts.eventId,
kind: repostKind,
publishedTo,
failedRelays,
};
}
export interface FetchEventsOpts {
accountId: string;
bunkerIndex?: number;
filter: Filter;
pool: SimplePool;
relays?: string[];
maxWait?: number;
}
/**
* Fetch events from relays using a filter.
* @see NIP-01 for filter format
*/
export async function fetchEvents(opts: FetchEventsOpts): Promise<FetchEventsResult> {
const conn = opts.bunkerIndex !== undefined
? getBunkerConnection(opts.accountId, opts.bunkerIndex)
: getFirstBunkerConnection(opts.accountId);
// Get relays to query - use READ relays for fetching (NIP-65)
const queryRelays = getReadRelays(opts.relays, conn);
if (queryRelays.length === 0) {
throw new Error("No relays available for query");
}
// Use pool.querySync for one-shot fetch with EOSE
const events = await opts.pool.querySync(queryRelays, opts.filter, {
maxWait: opts.maxWait ?? RELAY_FETCH_TIMEOUT_MS,
});
return {
events,
relaysQueried: queryRelays,
};
}
export interface PostArticleOpts {
accountId: string;
bunkerIndex?: number;
title: string;
content: string; // Markdown content
identifier: string; // d-tag for addressable event
summary?: string;
image?: string; // Header image URL
hashtags?: string[];
publishedAt?: number; // Original pub timestamp
isDraft?: boolean; // Use kind 30024 instead
pool: SimplePool;
relays?: string[];
}
/**
* Post a long-form article (kind:30023 or draft kind:30024).
* @see NIP-23 https://github.com/nostr-protocol/nips/blob/master/23.md
*/
export async function postArticle(opts: PostArticleOpts): Promise<PostArticleResult> {
const conn = opts.bunkerIndex !== undefined
? getBunkerConnection(opts.accountId, opts.bunkerIndex)
: getFirstBunkerConnection(opts.accountId);
if (!conn) {
throw new Error("No bunker connected. Use nostr_connect first.");
}
const now = Math.floor(Date.now() / 1000);
const tags: string[][] = [
["d", opts.identifier],
["title", opts.title],
];
if (opts.summary) tags.push(["summary", opts.summary]);
if (opts.image) tags.push(["image", opts.image]);
tags.push(["published_at", String(opts.publishedAt ?? now)]);
for (const tag of opts.hashtags ?? []) {
tags.push(["t", tag]);
}
const articleKind = opts.isDraft ? DraftLong : LongFormArticle;
const event: EventTemplate = {
kind: articleKind,
content: opts.content,
tags,
created_at: now,
};
const signed = await conn.signer.signEvent(event);
// Determine target relays
const targetRelays = getTargetRelays(opts.relays, conn);
const { publishedTo, failedRelays } = await publishToRelays(
opts.pool,
signed,
targetRelays
);
return {
eventId: signed.id,
pubkey: signed.pubkey,
title: opts.title,
identifier: opts.identifier,
kind: articleKind,
publishedTo,
failedRelays,
};
}

View File

@ -0,0 +1,439 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
connectBunker,
disconnectBunker,
getBunkerConnection,
getFirstBunkerConnection,
getAllBunkerConnections,
isBunkerConnected,
hasAnyBunkerConnected,
resetClientSecretKey,
loadPersistedState,
savePersistedState,
stripBunkerSecret,
getClientSecretKey,
clearPersistedState,
} from "./bunker-store.js";
// Mock runtime
vi.mock("./runtime.js", () => ({
getNostrRuntime: vi.fn(() => ({
state: {
resolveStateDir: vi.fn(() => "/tmp/test-clawdbot"),
},
})),
}));
// Mock nostr-tools/nip46
vi.mock("nostr-tools/nip46", () => {
const mockSigner = {
connect: vi.fn().mockResolvedValue(undefined),
sendRequest: vi.fn().mockResolvedValue("ack"),
getPublicKey: vi.fn().mockResolvedValue("user123pubkey456"),
close: vi.fn().mockResolvedValue(undefined),
signEvent: vi.fn(),
};
return {
parseBunkerInput: vi.fn(),
BunkerSigner: {
fromBunker: vi.fn().mockReturnValue(mockSigner),
},
};
});
// Mock nostr-tools
vi.mock("nostr-tools", () => ({
generateSecretKey: vi.fn().mockReturnValue(new Uint8Array(32).fill(1)),
SimplePool: vi.fn().mockImplementation(() => ({
publish: vi.fn().mockReturnValue([Promise.resolve("ok")]),
get: vi.fn().mockResolvedValue(null), // Default: no relay list found
})),
}));
// Mock nostr-tools/kinds
vi.mock("nostr-tools/kinds", () => ({
RelayList: 10002,
}));
// Mock fs
vi.mock("fs", () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
unlinkSync: vi.fn(),
}));
describe("bunker-store", () => {
let mockParseBunkerInput: ReturnType<typeof vi.fn>;
let mockBunkerSigner: {
fromBunker: ReturnType<typeof vi.fn>;
};
let mockSignerInstance: {
connect: ReturnType<typeof vi.fn>;
sendRequest: ReturnType<typeof vi.fn>;
getPublicKey: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
signEvent: ReturnType<typeof vi.fn>;
};
const accountId = "test-account";
const bunkerIndex = 0;
beforeEach(async () => {
// Reset state between tests
await disconnectBunker(accountId, bunkerIndex);
resetClientSecretKey(accountId, bunkerIndex);
vi.clearAllMocks();
// Get mocked modules
const nip46 = await import("nostr-tools/nip46");
mockParseBunkerInput = nip46.parseBunkerInput as ReturnType<typeof vi.fn>;
mockBunkerSigner = nip46.BunkerSigner as unknown as {
fromBunker: ReturnType<typeof vi.fn>;
};
mockSignerInstance = mockBunkerSigner.fromBunker() as {
connect: ReturnType<typeof vi.fn>;
sendRequest: ReturnType<typeof vi.fn>;
getPublicKey: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
signEvent: ReturnType<typeof vi.fn>;
};
});
afterEach(async () => {
await disconnectBunker(accountId, bunkerIndex);
});
describe("connectBunker", () => {
it("connects successfully with valid bunker URL", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123";
mockParseBunkerInput.mockResolvedValue({
pubkey: "fa984bd7deb8",
relays: ["wss://relay.example.com"],
secret: "abc123",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456");
const { connection: conn, isReconnect } = await connectBunker({
accountId,
bunkerIndex,
bunkerUrl,
pool: mockPool,
});
expect(conn.userPubkey).toBe("user123pubkey456");
expect(conn.bunkerPubkey).toBe("fa984bd7deb8");
expect(conn.relays).toEqual(["wss://relay.example.com"]);
expect(conn.userWriteRelays).toEqual([]); // No relay list found
expect(conn.connectedAt).toBeLessThanOrEqual(Date.now());
expect(conn.accountId).toBe(accountId);
expect(conn.bunkerIndex).toBe(bunkerIndex);
expect(isReconnect).toBe(false);
});
it("fetches user write relays from NIP-65 relay list", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue({
kind: 10002,
tags: [
["r", "wss://write.relay.com", "write"],
["r", "wss://both.relay.com"], // no marker = both read/write
["r", "wss://read.relay.com", "read"], // read-only, should be excluded from write
],
}),
} as unknown as import("nostr-tools").SimplePool;
const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123";
mockParseBunkerInput.mockResolvedValue({
pubkey: "fa984bd7deb8",
relays: ["wss://relay.example.com"],
secret: "abc123",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456");
const { connection: conn } = await connectBunker({
accountId,
bunkerIndex,
bunkerUrl,
pool: mockPool,
});
expect(conn.userWriteRelays).toEqual([
"wss://write.relay.com",
"wss://both.relay.com",
]);
expect(conn.userReadRelays).toEqual([
"wss://both.relay.com",
"wss://read.relay.com",
]);
});
it("throws on invalid bunker URL (parseBunkerInput returns null)", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue(null);
await expect(
connectBunker({ accountId, bunkerIndex, bunkerUrl: "invalid-url", pool: mockPool })
).rejects.toThrow("Invalid bunker URL format");
});
it("throws when bunker URL has no relays", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "fa984bd7deb8",
relays: [],
secret: "abc123",
});
await expect(
connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://fa984bd7deb8", pool: mockPool })
).rejects.toThrow("No relays in bunker URL");
});
it("handles 'already connected' error gracefully", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123";
mockParseBunkerInput.mockResolvedValue({
pubkey: "fa984bd7deb8",
relays: ["wss://relay.example.com"],
secret: "abc123",
});
// First call with secret fails with "already connected"
mockSignerInstance.sendRequest
.mockRejectedValueOnce(new Error("already connected"))
.mockResolvedValueOnce("ack"); // Retry without secret succeeds
mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456");
const { connection: conn, isReconnect } = await connectBunker({
accountId,
bunkerIndex,
bunkerUrl,
pool: mockPool,
});
expect(conn.userPubkey).toBe("user123pubkey456");
expect(isReconnect).toBe(true); // Should be marked as reconnect
// Should have been called twice: once with secret, once without
expect(mockSignerInstance.sendRequest).toHaveBeenCalledTimes(2);
});
it("retries without secret on 'invalid secret' error", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
const bunkerUrl = "bunker://fa984bd7deb8@wss://relay.example.com?secret=abc123";
mockParseBunkerInput.mockResolvedValue({
pubkey: "fa984bd7deb8",
relays: ["wss://relay.example.com"],
secret: "abc123",
});
// First call with secret fails with "invalid secret"
mockSignerInstance.sendRequest
.mockRejectedValueOnce(new Error("invalid secret"))
.mockResolvedValueOnce("ack"); // Retry without secret succeeds
mockSignerInstance.getPublicKey.mockResolvedValue("user123pubkey456");
const { connection: conn, isReconnect } = await connectBunker({
accountId,
bunkerIndex,
bunkerUrl,
pool: mockPool,
});
expect(conn.userPubkey).toBe("user123pubkey456");
expect(isReconnect).toBe(true);
expect(mockSignerInstance.sendRequest).toHaveBeenCalledTimes(2);
});
});
describe("getBunkerConnection", () => {
it("returns null when not connected", () => {
expect(getBunkerConnection(accountId, bunkerIndex)).toBeNull();
});
it("returns connection after connecting", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "test-bunker",
relays: ["wss://relay.example.com"],
secret: "secret",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey");
await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool });
const conn = getBunkerConnection(accountId, bunkerIndex);
expect(conn).not.toBeNull();
expect(conn?.userPubkey).toBe("test-user-pubkey");
});
});
describe("getFirstBunkerConnection", () => {
it("returns null when no bunker connected", () => {
expect(getFirstBunkerConnection(accountId)).toBeNull();
});
it("returns the first connected bunker", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "test-bunker",
relays: ["wss://relay.example.com"],
secret: "secret",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey");
await connectBunker({ accountId, bunkerIndex: 0, bunkerUrl: "bunker://test", pool: mockPool });
const conn = getFirstBunkerConnection(accountId);
expect(conn).not.toBeNull();
expect(conn?.bunkerIndex).toBe(0);
});
});
describe("getAllBunkerConnections", () => {
it("returns empty array when no bunkers connected", () => {
expect(getAllBunkerConnections(accountId)).toEqual([]);
});
});
describe("disconnectBunker", () => {
it("returns false when no connection exists", async () => {
const result = await disconnectBunker(accountId, bunkerIndex);
expect(result).toBe(false);
});
it("returns true and cleans up when connected", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "test-bunker",
relays: ["wss://relay.example.com"],
secret: "secret",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey");
await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool });
expect(isBunkerConnected(accountId, bunkerIndex)).toBe(true);
const result = await disconnectBunker(accountId, bunkerIndex);
expect(result).toBe(true);
expect(isBunkerConnected(accountId, bunkerIndex)).toBe(false);
expect(getBunkerConnection(accountId, bunkerIndex)).toBeNull();
expect(mockSignerInstance.close).toHaveBeenCalled();
});
});
describe("isBunkerConnected", () => {
it("returns false when not connected", () => {
expect(isBunkerConnected(accountId, bunkerIndex)).toBe(false);
});
it("returns true when connected", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "test-bunker",
relays: ["wss://relay.example.com"],
secret: "secret",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey");
await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool });
expect(isBunkerConnected(accountId, bunkerIndex)).toBe(true);
});
});
describe("hasAnyBunkerConnected", () => {
it("returns false when no bunker connected", () => {
expect(hasAnyBunkerConnected(accountId)).toBe(false);
});
it("returns true when any bunker connected", async () => {
const mockPool = {
publish: vi.fn(),
get: vi.fn().mockResolvedValue(null),
} as unknown as import("nostr-tools").SimplePool;
mockParseBunkerInput.mockResolvedValue({
pubkey: "test-bunker",
relays: ["wss://relay.example.com"],
secret: "secret",
});
mockSignerInstance.sendRequest.mockResolvedValue("ack");
mockSignerInstance.getPublicKey.mockResolvedValue("test-user-pubkey");
await connectBunker({ accountId, bunkerIndex, bunkerUrl: "bunker://test", pool: mockPool });
expect(hasAnyBunkerConnected(accountId)).toBe(true);
});
});
describe("stripBunkerSecret", () => {
it("strips secret from bunker URL", () => {
const url = "bunker://abc123?relay=wss://r.com&secret=mysecret";
const result = stripBunkerSecret(url);
// Secret should be removed, relay should remain
expect(result).not.toContain("secret");
expect(result).toContain("relay=");
expect(result).toContain("bunker://abc123");
});
it("returns URL without secret if no secret present", () => {
const url = "bunker://abc123?relay=wss://r.com";
const result = stripBunkerSecret(url);
// Should be essentially unchanged (maybe URL-normalized)
expect(result).not.toContain("secret");
expect(result).toContain("relay=");
expect(result).toContain("bunker://abc123");
});
it("handles invalid URLs gracefully", () => {
const url = "not-a-valid-url";
expect(stripBunkerSecret(url)).toBe("not-a-valid-url");
});
});
});

View File

@ -0,0 +1,397 @@
import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
import { join, dirname } from "path";
import { generateSecretKey } from "nostr-tools";
import { bytesToHex, hexToBytes } from "nostr-tools/utils";
import { BunkerSigner, parseBunkerInput } from "nostr-tools/nip46";
import { RelayList } from "nostr-tools/kinds";
import type { SimplePool } from "nostr-tools";
import os from "node:os";
import { getNostrRuntime } from "./runtime.js";
// ============================================================================
// Persistence - client key survives process restarts to maintain bunker session
// ============================================================================
const BUNKER_STATE_DIR = "nostr";
export interface PersistedBunkerState {
clientSecretKeyHex: string;
lastBunkerUrl?: string; // Without secret
}
/**
* Generate a unique key for the bunker connection map.
*/
function makeBunkerKey(accountId: string, bunkerIndex: number): string {
return `${accountId}-${bunkerIndex}`;
}
function getBunkerStatePath(accountId: string, bunkerIndex: number): string {
const stateDir = getNostrRuntime().state.resolveStateDir(process.env, os.homedir);
return join(stateDir, BUNKER_STATE_DIR, `bunker-state-${accountId}-${bunkerIndex}.json`);
}
export function loadPersistedState(accountId: string, bunkerIndex: number): PersistedBunkerState | null {
try {
const path = getBunkerStatePath(accountId, bunkerIndex);
if (existsSync(path)) {
return JSON.parse(readFileSync(path, "utf-8"));
}
} catch {
// Fail silently - will generate new key
}
return null;
}
export function savePersistedState(accountId: string, bunkerIndex: number, state: PersistedBunkerState): void {
try {
const path = getBunkerStatePath(accountId, bunkerIndex);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(state, null, 2));
} catch {
// Fail silently - persistence is best-effort
}
}
/**
* Strip the one-time secret from a bunker URL for storage/comparison.
* Bunker secrets are single-use, so we don't persist them.
*/
export function stripBunkerSecret(url: string): string {
try {
const parsed = new URL(url);
parsed.searchParams.delete("secret");
return parsed.toString();
} catch {
return url;
}
}
// ============================================================================
// Connection types
// ============================================================================
export interface BunkerConnection {
signer: BunkerSigner;
userPubkey: string; // The user's pubkey (from getPublicKey, NOT bunker pubkey)
bunkerPubkey: string; // The bunker's pubkey (from bunker URL)
relays: string[]; // From bunker URL
userWriteRelays: string[]; // From user's kind:10002 (NIP-65) - for publishing
userReadRelays: string[]; // From user's kind:10002 (NIP-65) - for fetching
connectedAt: number;
accountId: string;
bunkerIndex: number;
}
// Per-account bunker connections
const activeBunkers = new Map<string, BunkerConnection>();
// Per-account client secret keys
const clientSecretKeys = new Map<string, Uint8Array>();
/**
* Get or generate the client secret key for a specific bunker.
* Persists key to disk so reconnects use the same identity (bunker sees same client).
*/
export function getClientSecretKey(accountId: string, bunkerIndex: number): Uint8Array {
const key = makeBunkerKey(accountId, bunkerIndex);
let secretKey = clientSecretKeys.get(key);
if (!secretKey) {
const persisted = loadPersistedState(accountId, bunkerIndex);
if (persisted?.clientSecretKeyHex) {
secretKey = hexToBytes(persisted.clientSecretKeyHex);
} else {
secretKey = generateSecretKey();
savePersistedState(accountId, bunkerIndex, { clientSecretKeyHex: bytesToHex(secretKey) });
}
clientSecretKeys.set(key, secretKey);
}
return secretKey;
}
/** Connection timeout (ms) - increased to 90s because auth_url flows require user interaction in signer app */
const BUNKER_CONNECT_TIMEOUT_MS = 90000;
/** Timeout for fetching user's relay list (ms) */
const RELAY_FETCH_TIMEOUT_MS = 5000;
/**
* Fetch user's relays from their NIP-65 relay list (kind:10002).
* Returns both read and write relays separately.
*/
async function fetchUserRelays(
pool: SimplePool,
pubkey: string,
queryRelays: string[]
): Promise<{ readRelays: string[]; writeRelays: string[] }> {
try {
// Query bunker relays for the user's kind:10002 event
const relayListEvent = await Promise.race([
pool.get(queryRelays, {
kinds: [RelayList], // 10002
authors: [pubkey],
}),
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), RELAY_FETCH_TIMEOUT_MS)
),
]);
if (!relayListEvent) return { readRelays: [], writeRelays: [] };
// Parse "r" tags
const readRelays: string[] = [];
const writeRelays: string[] = [];
for (const tag of relayListEvent.tags) {
if (tag[0] !== "r" || !tag[1]) continue;
const relay = tag[1];
const marker = tag[2];
// No marker = both read and write
if (!marker) {
readRelays.push(relay);
writeRelays.push(relay);
} else if (marker === "read") {
readRelays.push(relay);
} else if (marker === "write") {
writeRelays.push(relay);
}
}
return { readRelays, writeRelays };
} catch {
return { readRelays: [], writeRelays: [] }; // Fail silently - relay list is optional
}
}
export interface ConnectBunkerResult {
connection: BunkerConnection;
isReconnect: boolean;
}
/**
* Custom error thrown when bunker requires auth_url approval.
* The user needs to open the URL to approve the connection in their signer app.
*/
export class BunkerAuthUrlError extends Error {
constructor(public readonly authUrl: string) {
super(`Bunker requires approval. Open this URL in your browser: ${authUrl}`);
this.name = "BunkerAuthUrlError";
}
}
export async function connectBunker(opts: {
accountId: string;
bunkerIndex: number;
bunkerUrl: string;
pool: SimplePool;
/** If false, treats this as a reconnect and won't send the secret */
isInitialConnection?: boolean;
}): Promise<ConnectBunkerResult> {
const key = makeBunkerKey(opts.accountId, opts.bunkerIndex);
// Disconnect existing local connection first
const existingConnection = activeBunkers.get(key);
if (existingConnection) {
await disconnectBunker(opts.accountId, opts.bunkerIndex);
}
// parseBunkerInput returns null on error (no throw)
const bp = await parseBunkerInput(opts.bunkerUrl);
if (!bp) {
throw new Error("Invalid bunker URL format");
}
if (bp.relays.length === 0) {
throw new Error("No relays in bunker URL");
}
// Track if we received an auth_url during connection
let pendingAuthUrl: string | null = null;
// fromBunker() is SYNCHRONOUS - returns immediately
const signer = BunkerSigner.fromBunker(getClientSecretKey(opts.accountId, opts.bunkerIndex), bp, {
pool: opts.pool,
onauth: (authUrl: string) => {
// Bunker is requesting authorization - store the URL
pendingAuthUrl = authUrl;
},
});
// Helper to send connect request with timeout
const connectWithSecret = async (secret: string | null) => {
const connectPromise = signer.sendRequest("connect", [bp.pubkey, secret || ""]);
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
// If we have an auth_url pending, throw that instead of generic timeout
if (pendingAuthUrl) {
reject(new BunkerAuthUrlError(pendingAuthUrl));
} else {
reject(new Error("Bunker connection timeout"));
}
}, BUNKER_CONNECT_TIMEOUT_MS);
});
await Promise.race([connectPromise, timeoutPromise]);
};
// Determine if this is initial connection or reconnect
const isInitial = opts.isInitialConnection !== false;
const secret = isInitial ? bp.secret : null;
let isReconnect = !isInitial;
try {
await connectWithSecret(secret);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
// Retry without secret (for one-time secrets already consumed, or reconnects)
// Pidgeon pattern: try without secret if the bunker rejects
if (secret && (msg.includes("already connected") || msg.includes("invalid secret"))) {
try {
await connectWithSecret(null);
} catch (retryErr) {
const retryMsg = retryErr instanceof Error ? retryErr.message : String(retryErr);
// If retry also says "already connected", that's fine - bunker still knows us
if (!retryMsg.includes("already connected")) {
throw retryErr;
}
}
isReconnect = true; // If we retried without secret, it's effectively a reconnect
} else if (msg.includes("already connected")) {
// Bunker has existing session with this client pubkey - that's OK, continue
isReconnect = true;
} else {
throw err;
}
}
// getPublicKey() returns the USER's pubkey (cached after first call)
const userPubkey = await signer.getPublicKey();
// Fetch user's NIP-65 relay list for better publishing/fetching coverage
const { readRelays, writeRelays } = await fetchUserRelays(
opts.pool,
userPubkey,
bp.relays
);
const connection: BunkerConnection = {
signer,
userPubkey,
bunkerPubkey: bp.pubkey,
relays: bp.relays,
userWriteRelays: writeRelays,
userReadRelays: readRelays,
connectedAt: Date.now(),
accountId: opts.accountId,
bunkerIndex: opts.bunkerIndex,
};
activeBunkers.set(key, connection);
// Persist state after successful connect (strip secret from URL)
const strippedUrl = stripBunkerSecret(opts.bunkerUrl);
savePersistedState(opts.accountId, opts.bunkerIndex, {
clientSecretKeyHex: bytesToHex(getClientSecretKey(opts.accountId, opts.bunkerIndex)),
lastBunkerUrl: strippedUrl,
});
return { connection, isReconnect };
}
export function getBunkerConnection(accountId: string, bunkerIndex: number): BunkerConnection | null {
return activeBunkers.get(makeBunkerKey(accountId, bunkerIndex)) ?? null;
}
/**
* Get the first connected bunker for an account (convenience for single-bunker usage).
*/
export function getFirstBunkerConnection(accountId: string): BunkerConnection | null {
// Check bunker index 0 first (most common case)
const first = activeBunkers.get(makeBunkerKey(accountId, 0));
if (first) return first;
// Search for any connected bunker for this account
for (const [key, conn] of activeBunkers.entries()) {
if (key.startsWith(`${accountId}-`)) {
return conn;
}
}
return null;
}
/**
* Get all connected bunkers for an account.
*/
export function getAllBunkerConnections(accountId: string): BunkerConnection[] {
const connections: BunkerConnection[] = [];
for (const [key, conn] of activeBunkers.entries()) {
if (key.startsWith(`${accountId}-`)) {
connections.push(conn);
}
}
return connections.sort((a, b) => a.bunkerIndex - b.bunkerIndex);
}
export async function disconnectBunker(accountId: string, bunkerIndex: number): Promise<boolean> {
const key = makeBunkerKey(accountId, bunkerIndex);
const connection = activeBunkers.get(key);
if (!connection) return false;
await connection.signer.close();
activeBunkers.delete(key);
return true;
}
/**
* Disconnect all bunkers for an account.
*/
export async function disconnectAllBunkers(accountId: string): Promise<number> {
const keysToDelete: string[] = [];
for (const [key, conn] of activeBunkers.entries()) {
if (key.startsWith(`${accountId}-`)) {
keysToDelete.push(key);
await conn.signer.close();
}
}
for (const key of keysToDelete) {
activeBunkers.delete(key);
}
return keysToDelete.length;
}
export function isBunkerConnected(accountId: string, bunkerIndex: number): boolean {
return activeBunkers.has(makeBunkerKey(accountId, bunkerIndex));
}
/**
* Check if any bunker is connected for an account.
*/
export function hasAnyBunkerConnected(accountId: string): boolean {
for (const key of activeBunkers.keys()) {
if (key.startsWith(`${accountId}-`)) {
return true;
}
}
return false;
}
/**
* Reset the client secret key for a specific bunker - useful for testing
*/
export function resetClientSecretKey(accountId: string, bunkerIndex: number): void {
clientSecretKeys.delete(makeBunkerKey(accountId, bunkerIndex));
}
/**
* Clear all persisted bunker state for a specific bunker and reset in-memory client key.
* This is a full reset - next connect will generate a fresh identity.
*/
export function clearPersistedState(accountId: string, bunkerIndex: number): void {
try {
const path = getBunkerStatePath(accountId, bunkerIndex);
if (existsSync(path)) {
unlinkSync(path);
}
} catch {
// Fail silently
}
// Also reset the in-memory client key so next connect generates fresh identity
clientSecretKeys.delete(makeBunkerKey(accountId, bunkerIndex));
}

View File

@ -53,6 +53,22 @@ export const NostrProfileSchema = z.object({
export type NostrProfile = z.infer<typeof NostrProfileSchema>;
/**
* Bunker account entry for NIP-46 remote signing
*/
export const BunkerAccountSchema = z.object({
/** Display name for this bunker account */
name: z.string().optional(),
/** bunker:// URL (secret stripped after connection) */
bunkerUrl: z.string(),
/** Cached user pubkey after successful connection */
userPubkey: z.string().optional(),
/** Connection timestamp */
connectedAt: z.number().optional(),
});
export type BunkerAccountConfig = z.infer<typeof BunkerAccountSchema>;
/**
* Zod schema for channels.nostr.* configuration
*/
@ -72,6 +88,9 @@ export const NostrConfigSchema = z.object({
/** WebSocket relay URLs to connect to */
relays: z.array(z.string()).optional(),
/** Bunker accounts for NIP-46 remote signing */
bunkerAccounts: z.array(BunkerAccountSchema).optional(),
/** DM access policy: pairing, allowlist, open, or disabled */
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),

View File

@ -0,0 +1,355 @@
/**
* Nostr Bunker HTTP Handler
*
* Handles HTTP requests for bunker management:
* - GET /api/channels/nostr/:accountId/bunker - List all bunker account statuses
* - GET /api/channels/nostr/:accountId/bunker/:index - Get specific bunker status
* - POST /api/channels/nostr/:accountId/bunker/:index - Connect specific bunker
* - DELETE /api/channels/nostr/:accountId/bunker/:index - Disconnect specific bunker
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import {
isBunkerConnected,
loadPersistedState,
clearPersistedState,
disconnectBunker,
connectBunker,
getBunkerConnection,
getAllBunkerConnections,
BunkerAuthUrlError,
stripBunkerSecret,
} from "./bunker-store.js";
import { getSharedPool } from "./nostr-bus.js";
import type { BunkerAccountConfig } from "./config-schema.js";
// ============================================================================
// Types
// ============================================================================
export interface NostrBunkerHttpContext {
/** Get bunker accounts from config */
getBunkerAccounts: (accountId: string) => BunkerAccountConfig[];
/** Update a bunker account in config (after successful connect) */
updateBunkerAccount: (
accountId: string,
bunkerIndex: number,
update: Partial<BunkerAccountConfig>
) => Promise<void>;
/** Clear bunkerUrl from config for a specific bunker */
clearConfigBunkerUrl: (accountId: string, bunkerIndex: number) => Promise<void>;
/** Logger */
log?: {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
}
// ============================================================================
// Request Helpers
// ============================================================================
function sendJson(res: ServerResponse, status: number, body: unknown): void {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
async function readJsonBody(req: IncomingMessage, maxBytes = 16 * 1024): Promise<unknown> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > maxBytes) {
reject(new Error("Request body too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const body = Buffer.concat(chunks).toString("utf-8");
resolve(body ? JSON.parse(body) : {});
} catch {
reject(new Error("Invalid JSON"));
}
});
req.on("error", reject);
});
}
interface BunkerPathParams {
accountId: string;
bunkerIndex?: number;
}
function parseBunkerPathParams(pathname: string): BunkerPathParams | null {
// Match: /api/channels/nostr/:accountId/bunker/:index (specific bunker)
const indexMatch = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/bunker\/(\d+)$/);
if (indexMatch) {
return {
accountId: indexMatch[1],
bunkerIndex: parseInt(indexMatch[2], 10),
};
}
// Match: /api/channels/nostr/:accountId/bunker (all bunkers)
const allMatch = pathname.match(/^\/api\/channels\/nostr\/([^/]+)\/bunker$/);
if (allMatch) {
return {
accountId: allMatch[1],
};
}
return null;
}
// ============================================================================
// HTTP Handler
// ============================================================================
export function createNostrBunkerHttpHandler(
ctx: NostrBunkerHttpContext
): (req: IncomingMessage, res: ServerResponse) => Promise<boolean> {
return async (req, res) => {
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
// Only handle /api/channels/nostr/:accountId/bunker paths
if (!url.pathname.includes("/bunker")) {
return false;
}
const params = parseBunkerPathParams(url.pathname);
if (!params) {
return false;
}
const { accountId, bunkerIndex } = params;
// Handle different HTTP methods
try {
if (req.method === "GET") {
if (bunkerIndex !== undefined) {
return handleGetBunkerStatus(accountId, bunkerIndex, ctx, res);
} else {
return handleGetAllBunkerStatus(accountId, ctx, res);
}
}
if (req.method === "POST" && bunkerIndex !== undefined) {
return await handleConnectBunker(accountId, bunkerIndex, ctx, req, res);
}
if (req.method === "DELETE" && bunkerIndex !== undefined) {
return await handleDisconnectBunker(accountId, bunkerIndex, ctx, res);
}
// Method not allowed
sendJson(res, 405, { ok: false, error: "Method not allowed" });
return true;
} catch (err) {
ctx.log?.error(`Bunker HTTP error: ${String(err)}`);
sendJson(res, 500, { ok: false, error: "Internal server error" });
return true;
}
};
}
// ============================================================================
// GET /api/channels/nostr/:accountId/bunker - List all bunkers
// ============================================================================
function handleGetAllBunkerStatus(
accountId: string,
ctx: NostrBunkerHttpContext,
res: ServerResponse
): true {
const bunkerAccounts = ctx.getBunkerAccounts(accountId);
const connections = getAllBunkerConnections(accountId);
const bunkers = bunkerAccounts.map((account, index) => {
const connected = isBunkerConnected(accountId, index);
const connection = connections.find((c) => c.bunkerIndex === index);
const persistedState = loadPersistedState(accountId, index);
return {
index,
name: account.name,
bunkerUrl: account.bunkerUrl ? stripBunkerSecret(account.bunkerUrl) : null,
connected,
userPubkey: connection?.userPubkey ?? account.userPubkey ?? null,
connectedAt: connection?.connectedAt ?? account.connectedAt ?? null,
lastBunkerUrl: persistedState?.lastBunkerUrl ?? null,
};
});
sendJson(res, 200, {
ok: true,
bunkers,
});
return true;
}
// ============================================================================
// GET /api/channels/nostr/:accountId/bunker/:index - Get specific bunker status
// ============================================================================
function handleGetBunkerStatus(
accountId: string,
bunkerIndex: number,
ctx: NostrBunkerHttpContext,
res: ServerResponse
): true {
const bunkerAccounts = ctx.getBunkerAccounts(accountId);
const account = bunkerAccounts[bunkerIndex];
if (!account) {
sendJson(res, 404, { ok: false, error: `Bunker index ${bunkerIndex} not found` });
return true;
}
const connected = isBunkerConnected(accountId, bunkerIndex);
const connection = getBunkerConnection(accountId, bunkerIndex);
const persistedState = loadPersistedState(accountId, bunkerIndex);
sendJson(res, 200, {
ok: true,
index: bunkerIndex,
name: account.name,
bunkerUrl: account.bunkerUrl ? stripBunkerSecret(account.bunkerUrl) : null,
connected,
userPubkey: connection?.userPubkey ?? account.userPubkey ?? null,
bunkerPubkey: connection?.bunkerPubkey ?? null,
relays: connection?.relays ?? [],
userWriteRelays: connection?.userWriteRelays ?? [],
userReadRelays: connection?.userReadRelays ?? [],
connectedAt: connection?.connectedAt ?? account.connectedAt ?? null,
lastBunkerUrl: persistedState?.lastBunkerUrl ?? null,
});
return true;
}
// ============================================================================
// POST /api/channels/nostr/:accountId/bunker/:index - Connect bunker
// ============================================================================
async function handleConnectBunker(
accountId: string,
bunkerIndex: number,
ctx: NostrBunkerHttpContext,
req: IncomingMessage,
res: ServerResponse
): Promise<true> {
// Parse request body
let body: { bunkerUrl?: string };
try {
body = (await readJsonBody(req)) as { bunkerUrl?: string };
} catch (err) {
sendJson(res, 400, { ok: false, error: String(err) });
return true;
}
// Get bunker URL from request or config
const bunkerAccounts = ctx.getBunkerAccounts(accountId);
const account = bunkerAccounts[bunkerIndex];
const bunkerUrl = body.bunkerUrl ?? account?.bunkerUrl;
if (!bunkerUrl) {
sendJson(res, 400, { ok: false, error: "No bunker URL provided" });
return true;
}
// Check if this is a reconnect (same URL minus secret)
const persistedState = loadPersistedState(accountId, bunkerIndex);
const strippedUrl = stripBunkerSecret(bunkerUrl);
const isInitialConnection =
!persistedState?.lastBunkerUrl || strippedUrl !== persistedState.lastBunkerUrl;
ctx.log?.info(`[${accountId}] Connecting bunker ${bunkerIndex}${isInitialConnection ? "" : " (reconnect)"}`);
try {
const pool = getSharedPool();
const { connection, isReconnect } = await connectBunker({
accountId,
bunkerIndex,
bunkerUrl,
pool,
isInitialConnection,
});
// Update config with user pubkey and connected timestamp
const urlWithoutSecret = stripBunkerSecret(bunkerUrl);
await ctx.updateBunkerAccount(accountId, bunkerIndex, {
bunkerUrl: urlWithoutSecret,
userPubkey: connection.userPubkey,
connectedAt: connection.connectedAt,
});
ctx.log?.info(
`[${accountId}] Bunker ${bunkerIndex} connected${isReconnect ? " (reconnected)" : ""} as ${connection.userPubkey.slice(0, 8)}...`
);
sendJson(res, 200, {
ok: true,
userPubkey: connection.userPubkey,
bunkerPubkey: connection.bunkerPubkey,
relays: connection.relays,
userWriteRelays: connection.userWriteRelays,
userReadRelays: connection.userReadRelays,
connectedAt: connection.connectedAt,
isReconnect,
});
} catch (err) {
if (err instanceof BunkerAuthUrlError) {
ctx.log?.info(`[${accountId}] Bunker ${bunkerIndex} requires auth_url approval`);
sendJson(res, 200, {
ok: false,
needsAuth: true,
authUrl: err.authUrl,
error: "Bunker requires approval",
});
} else {
ctx.log?.error(`[${accountId}] Bunker ${bunkerIndex} connect error: ${String(err)}`);
sendJson(res, 400, {
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
}
return true;
}
// ============================================================================
// DELETE /api/channels/nostr/:accountId/bunker/:index - Disconnect bunker
// ============================================================================
async function handleDisconnectBunker(
accountId: string,
bunkerIndex: number,
ctx: NostrBunkerHttpContext,
res: ServerResponse
): Promise<true> {
ctx.log?.info(`[${accountId}] Disconnecting bunker ${bunkerIndex}`);
// Disconnect in-memory connection
const wasConnected = await disconnectBunker(accountId, bunkerIndex);
// Clear persisted state file (clientSecretKeyHex + lastBunkerUrl)
clearPersistedState(accountId, bunkerIndex);
// Clear bunkerUrl from config
await ctx.clearConfigBunkerUrl(accountId, bunkerIndex);
ctx.log?.info(`[${accountId}] Bunker ${bunkerIndex} disconnected and state cleared`);
sendJson(res, 200, { ok: true, wasConnected });
return true;
}

View File

@ -40,6 +40,23 @@ import {
export const DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"];
// ============================================================================
// Shared Pool (singleton for bunker tools)
// ============================================================================
let sharedPool: SimplePool | null = null;
/**
* Get a shared SimplePool instance for bunker tools.
* Uses a singleton pattern to reuse connections.
*/
export function getSharedPool(): SimplePool {
if (!sharedPool) {
sharedPool = new SimplePool();
}
return sharedPool;
}
// ============================================================================
// Constants
// ============================================================================
@ -855,8 +872,13 @@ export function normalizePubkey(input: string): string {
if (decoded.type !== "npub") {
throw new Error("Invalid npub key");
}
// Convert Uint8Array to hex string
return Array.from(decoded.data)
// In nostr-tools v2+, decoded.data is already a hex string
// In older versions, it was a Uint8Array
if (typeof decoded.data === "string") {
return decoded.data.toLowerCase();
}
// Fallback for Uint8Array (older nostr-tools)
return Array.from(decoded.data as Uint8Array)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}

View File

@ -1,13 +1,14 @@
import type { MoltbotConfig } from "clawdbot/plugin-sdk";
import { getPublicKeyFromPrivate, type DmProtocol } from "./nostr-bus.js";
import { DEFAULT_RELAYS } from "./nostr-bus.js";
import type { NostrProfile } from "./config-schema.js";
import type { NostrProfile, BunkerAccountConfig } from "./config-schema.js";
export interface NostrAccountConfig {
enabled?: boolean;
name?: string;
privateKey?: string;
relays?: string[];
bunkerAccounts?: BunkerAccountConfig[];
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
dmProtocol?: DmProtocol;
allowFrom?: Array<string | number>;
@ -22,6 +23,7 @@ export interface ResolvedNostrAccount {
privateKey: string;
publicKey: string;
relays: string[];
bunkerAccounts: BunkerAccountConfig[];
dmProtocol: DmProtocol;
profile?: NostrProfile;
config: NostrAccountConfig;
@ -87,6 +89,7 @@ export function resolveNostrAccount(opts: {
privateKey,
publicKey,
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
bunkerAccounts: nostrCfg?.bunkerAccounts ?? [],
dmProtocol: nostrCfg?.dmProtocol ?? "dual",
profile: nostrCfg?.profile,
config: {
@ -94,6 +97,7 @@ export function resolveNostrAccount(opts: {
name: nostrCfg?.name,
privateKey: nostrCfg?.privateKey,
relays: nostrCfg?.relays,
bunkerAccounts: nostrCfg?.bunkerAccounts,
dmPolicy: nostrCfg?.dmPolicy,
dmProtocol: nostrCfg?.dmProtocol,
allowFrom: nostrCfg?.allowFrom,