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:
parent
3130c65e74
commit
b46d0395a6
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
1023
extensions/nostr/src/agent-tools.ts
Normal file
1023
extensions/nostr/src/agent-tools.ts
Normal file
File diff suppressed because it is too large
Load Diff
531
extensions/nostr/src/bunker-actions.test.ts
Normal file
531
extensions/nostr/src/bunker-actions.test.ts
Normal 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"],
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
515
extensions/nostr/src/bunker-actions.ts
Normal file
515
extensions/nostr/src/bunker-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
439
extensions/nostr/src/bunker-store.test.ts
Normal file
439
extensions/nostr/src/bunker-store.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
397
extensions/nostr/src/bunker-store.ts
Normal file
397
extensions/nostr/src/bunker-store.ts
Normal 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));
|
||||
}
|
||||
@ -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(),
|
||||
|
||||
|
||||
355
extensions/nostr/src/nostr-bunker-http.ts
Normal file
355
extensions/nostr/src/nostr-bunker-http.ts
Normal 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;
|
||||
}
|
||||
@ -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("");
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user