Compare commits

...

3 Commits

Author SHA1 Message Date
Darshil
32b175a424 fix(nostr): validate relay/auth config and add coverage 2026-02-05 13:01:34 -08:00
Nash
2951c0e1bc fix(nostr): wire NIP-42 AUTH and fix npub decoding
- Connect authHandler to SimplePool via onauth callback
- Pass onauth to subscribeMany and publish calls
- Handle auth-required relay challenges automatically
- Fix npub decoding: validate type === 'npub' before cast
- Use 'npub1' prefix check for safety

Addresses Greptile review comments on PR #9825
2026-02-05 18:44:47 +00:00
Nash
22f769d18e feat(nostr): upgrade to NIP-17 with NIP-65 relay discovery
- Add NIP-17 gift-wrapped DMs (replaces NIP-04)
- Add NIP-65 relay discovery for better delivery
- Add NIP-42 AUTH support for auth-required relays
- Add dmProtocol config option for backwards compat

BREAKING CHANGE: Default DM encryption now uses NIP-17.
Set dmProtocol: 'nip04' for legacy compatibility.
2026-02-05 18:38:55 +00:00
15 changed files with 2127 additions and 82 deletions

View File

@@ -1,5 +1,16 @@
# Changelog
## 2026.2.5
### Changes
- Upgrade default outbound DM protocol to NIP-17, with `dmProtocol: "nip04"` fallback.
- Keep inbound compatibility by reading both NIP-04 (`kind:4`) and NIP-17 (`kind:1059`) DMs.
- Add NIP-42 AUTH signing support for auth-required relays.
- Add NIP-65 relay discovery with safer relay URL filtering and fallback behavior.
- Fix `npub` normalization to decode directly to hex pubkeys.
- Add regression/unit tests for NIP-42 auth signing, NIP-65 relay handling, and `npub` normalization.
## 2026.2.4
### Changes

View File

@@ -1,6 +1,6 @@
# @openclaw/nostr
Nostr DM channel plugin for OpenClaw using NIP-04 encrypted direct messages.
Nostr DM channel plugin for OpenClaw using **NIP-17 gift-wrapped messages** (default) or NIP-04 encrypted DMs (legacy).
## Overview
@@ -8,7 +8,16 @@ This extension adds Nostr as a messaging channel to OpenClaw. It enables your bo
- Receive encrypted DMs from Nostr users
- Send encrypted responses back
- Work with any NIP-04 compatible Nostr client (Damus, Amethyst, etc.)
- Work with NIP-17 compatible clients (0xchat, Amethyst, Damus, Primal, etc.)
- Automatically discover recipient's preferred relays (NIP-65)
- Authenticate with relays that require NIP-42 AUTH challenges
## What's New in v2
- **NIP-17 by default** — Gift-wrapped messages hide sender/recipient from relays
- **NIP-65 relay discovery** — Finds recipient's preferred relays before sending
- **NIP-42 auth support** — Handles auth-required relays automatically
- **Backwards compatible** — Set `dmProtocol: "nip04"` if you need legacy support
## Installation
@@ -23,21 +32,17 @@ openclaw plugins install @openclaw/nostr
```bash
# Using nak CLI
nak key generate
# Or use any Nostr key generator
```
2. Add to your config:
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.damus.io", "wss://nos.lol"]
}
}
}
```yaml
channels:
nostr:
privateKey: "${NOSTR_PRIVATE_KEY}"
relays:
- wss://relay.damus.io
- wss://nos.lol
```
3. Set the environment variable:
@@ -54,11 +59,45 @@ openclaw plugins install @openclaw/nostr
| ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- |
| `privateKey` | string | required | Bot's private key (nsec or hex format) |
| `relays` | string[] | `["wss://relay.damus.io", "wss://nos.lol"]` | WebSocket relay URLs |
| `dmProtocol` | string | `"nip17"` | `"nip17"` (gift-wrapped) or `"nip04"` (legacy) |
| `dmPolicy` | string | `"pairing"` | Access control: `pairing`, `allowlist`, `open`, `disabled` |
| `allowFrom` | string[] | `[]` | Allowed sender pubkeys (npub or hex) |
| `enabled` | boolean | `true` | Enable/disable the channel |
| `name` | string | - | Display name for the account |
## DM Protocols
### NIP-17 (Default, Recommended)
Gift-wrapped messages provide metadata privacy:
- Sender and recipient pubkeys hidden from relays
- Forward secrecy with ephemeral keys
- Supported by modern clients (0xchat, Amethyst, Damus, Primal)
### NIP-04 (Legacy)
Use only for backwards compatibility:
- Sender/recipient visible to relays
- Older clients may only support this
```yaml
channels:
nostr:
dmProtocol: "nip04" # Use legacy protocol
```
## NIP-65 Relay Discovery
When sending DMs, the plugin automatically discovers the recipient's preferred relays:
1. **kind:10050** — DM inbox relays (NIP-17 specific)
2. **kind:10002** — General relay list
3. **Fallback** — Your configured relays
This ensures messages are delivered to where the recipient actually reads them.
## Access Control
### DM Policies
@@ -70,50 +109,41 @@ openclaw plugins install @openclaw/nostr
### Example: Allowlist Mode
```json
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "0123456789abcdef..."]
}
}
}
```yaml
channels:
nostr:
privateKey: "${NOSTR_PRIVATE_KEY}"
dmPolicy: "allowlist"
allowFrom:
- "npub1abc..."
- "0123456789abcdef..."
```
## Testing
## Migration from v1
### Local Relay (Recommended)
If you're upgrading from the NIP-04-only version:
```bash
# Using strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
# Configure openclaw to use local relay
"relays": ["ws://localhost:7777"]
```
### Manual Test
1. Start the gateway with Nostr configured
2. Open Damus, Amethyst, or another Nostr client
3. Send a DM to your bot's npub
4. Verify the bot responds
1. **Default behavior changed** — DMs now use NIP-17
2. **Most users**: No action needed (NIP-17 is better)
3. **Legacy clients**: Add `dmProtocol: "nip04"` to keep old behavior
4. **Inbound compatibility**: The plugin still reads both NIP-04 and NIP-17 inbound DMs
## Protocol Support
| NIP | Status | Notes |
| ------ | --------- | ---------------------- |
| NIP-01 | Supported | Basic event structure |
| NIP-04 | Supported | Encrypted DMs (kind:4) |
| NIP-17 | Planned | Gift-wrapped DMs (v2) |
| NIP | Status | Notes |
| ------ | --------- | ----------------------------- |
| NIP-01 | Supported | Basic event structure |
| NIP-04 | Supported | Legacy encrypted DMs (opt-in) |
| NIP-17 | Supported | Gift-wrapped DMs (default) |
| NIP-42 | Supported | Relay AUTH challenge handling |
| NIP-65 | Supported | Relay list discovery |
## Security Notes
- Private keys are never logged
- Event signatures are verified before processing
- Use environment variables for keys, never commit to config files
- NIP-17 hides metadata from relays (recommended)
- Consider using `allowlist` mode in production
## Troubleshooting
@@ -124,12 +154,24 @@ docker run -p 7777:7777 ghcr.io/hoytech/strfry
2. Check relay connectivity
3. Ensure `enabled` is not set to `false`
4. Check the bot's public key matches what you're sending to
5. If using NIP-17, ensure the sender's client supports it
### Messages not being delivered
1. Check relay URLs are correct (must use `wss://`)
2. Verify relays are online and accepting connections
3. Check for rate limiting (reduce message frequency)
3. NIP-65 will try to find recipient's preferred relays (public `wss://` only)
4. Check for rate limiting (reduce message frequency)
### Legacy client compatibility
If the recipient uses an old client:
```yaml
channels:
nostr:
dmProtocol: "nip04"
```
## License

View File

@@ -223,6 +223,7 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
accountId: account.accountId,
privateKey: account.privateKey,
relays: account.relays,
dmProtocol: account.dmProtocol,
onMessage: async (senderPubkey, text, reply) => {
ctx.log?.debug(`[${account.accountId}] DM from ${senderPubkey}: ${text.slice(0, 50)}...`);

View File

@@ -72,6 +72,16 @@ export const NostrConfigSchema = z.object({
/** WebSocket relay URLs to connect to */
relays: z.array(z.string()).optional(),
/**
* DM protocol version:
* - "nip17" (default): Gift-wrapped messages with metadata privacy
* - "nip04": Legacy encrypted DMs (metadata visible to relays)
*
* NIP-17 is recommended. Use NIP-04 only for backwards compatibility
* with very old clients that don't support NIP-17.
*/
dmProtocol: z.enum(["nip17", "nip04"]).default("nip17").optional(),
/** DM access policy: pairing, allowlist, open, or disabled */
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),

View File

@@ -0,0 +1,287 @@
/**
* Tests for NIP-17 gift wrap implementation
*/
import { getPublicKey } from "nostr-tools";
import { describe, it, expect } from "vitest";
import {
hexToBytes,
bytesToHex,
normalizeNostrTarget,
looksLikeNostrId,
createGiftWrap,
unwrapGiftWrap,
getPublicKeyFromPrivate,
} from "./nip17.js";
// Test keys (DO NOT use in production)
const TEST_SENDER_SK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_RECIPIENT_SK = "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210";
describe("hexToBytes", () => {
it("converts hex string to Uint8Array", () => {
const hex = "0102030405060708";
const bytes = hexToBytes(hex);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(8);
expect(Array.from(bytes)).toEqual([1, 2, 3, 4, 5, 6, 7, 8]);
});
it("handles uppercase hex", () => {
const hex = "AABBCCDD";
const bytes = hexToBytes(hex);
expect(bytes[0]).toBe(0xaa);
expect(bytes[1]).toBe(0xbb);
expect(bytes[2]).toBe(0xcc);
expect(bytes[3]).toBe(0xdd);
});
it("handles 64-char private key", () => {
const bytes = hexToBytes(TEST_SENDER_SK);
expect(bytes.length).toBe(32);
});
});
describe("bytesToHex", () => {
it("converts Uint8Array to hex string", () => {
const bytes = new Uint8Array([1, 2, 3, 4, 170, 187, 204, 221]);
const hex = bytesToHex(bytes);
expect(hex).toBe("01020304aabbccdd");
});
it("roundtrips with hexToBytes", () => {
const original = TEST_SENDER_SK;
const bytes = hexToBytes(original);
const hex = bytesToHex(bytes);
expect(hex).toBe(original);
});
});
describe("getPublicKeyFromPrivate", () => {
it("derives correct public key", () => {
const skBytes = hexToBytes(TEST_SENDER_SK);
const pk = getPublicKeyFromPrivate(skBytes);
expect(pk).toHaveLength(64);
expect(/^[a-f0-9]{64}$/.test(pk)).toBe(true);
});
it("matches nostr-tools getPublicKey", () => {
const skBytes = hexToBytes(TEST_SENDER_SK);
const ourPk = getPublicKeyFromPrivate(skBytes);
const theirPk = getPublicKey(skBytes);
expect(ourPk).toBe(theirPk);
});
});
describe("normalizeNostrTarget", () => {
const validHexPubkey = "c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
const validNpub = "npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx";
it("accepts valid hex pubkey", () => {
expect(normalizeNostrTarget(validHexPubkey)).toBe(validHexPubkey);
});
it("normalizes hex to lowercase", () => {
const uppercase = validHexPubkey.toUpperCase();
expect(normalizeNostrTarget(uppercase)).toBe(validHexPubkey);
});
it("decodes npub to hex", () => {
const result = normalizeNostrTarget(validNpub);
expect(result).toBe(validHexPubkey);
});
it("returns null for invalid input", () => {
expect(normalizeNostrTarget("invalid")).toBeNull();
expect(normalizeNostrTarget("npub1short")).toBeNull();
expect(normalizeNostrTarget("abc123")).toBeNull();
expect(normalizeNostrTarget("")).toBeNull();
});
it("returns null for wrong length hex", () => {
expect(normalizeNostrTarget("c220169537593d7126e9842f31a8d4d5")).toBeNull(); // 32 chars
expect(
normalizeNostrTarget("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2aa"),
).toBeNull(); // 66 chars
});
});
describe("looksLikeNostrId", () => {
it("recognizes npub format", () => {
expect(
looksLikeNostrId("npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx"),
).toBe(true);
});
it("recognizes hex pubkey", () => {
expect(
looksLikeNostrId("c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2"),
).toBe(true);
});
it("rejects invalid formats", () => {
expect(looksLikeNostrId("invalid")).toBe(false);
expect(looksLikeNostrId("npub1short")).toBe(false);
expect(looksLikeNostrId("abc123")).toBe(false);
expect(looksLikeNostrId("")).toBe(false);
});
it("rejects nsec (private keys)", () => {
expect(looksLikeNostrId("nsec1abc")).toBe(false);
});
});
describe("createGiftWrap", () => {
const senderSkBytes = hexToBytes(TEST_SENDER_SK);
const recipientSkBytes = hexToBytes(TEST_RECIPIENT_SK);
const recipientPk = getPublicKey(recipientSkBytes);
it("creates a kind:1059 event", () => {
const { event, eventId } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
expect(event.kind).toBe(1059);
expect(event.id).toBe(eventId);
expect(eventId).toHaveLength(64);
});
it("includes recipient in p-tag", () => {
const { event } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
const pTags = event.tags.filter((t) => t[0] === "p");
expect(pTags.length).toBeGreaterThan(0);
expect(pTags.some((t) => t[1] === recipientPk)).toBe(true);
});
it("has encrypted content", () => {
const { event } = createGiftWrap(recipientPk, "Secret message", senderSkBytes);
// Content should not contain the plaintext
expect(event.content).not.toContain("Secret message");
// Content should be non-empty (encrypted)
expect(event.content.length).toBeGreaterThan(0);
});
it("uses ephemeral pubkey (not sender's)", () => {
const senderPk = getPublicKey(senderSkBytes);
const { event } = createGiftWrap(recipientPk, "Hello!", senderSkBytes);
// Gift wrap pubkey should be ephemeral, not the sender
expect(event.pubkey).not.toBe(senderPk);
});
it("accepts npub format for recipient", () => {
const recipientNpub = "npub1cgspd9fhty7hzfhfsshnr2x56haxdcn3ecuk7ykact29tku9t0eqtveawx";
const { event } = createGiftWrap(recipientNpub, "Hello!", senderSkBytes);
expect(event.kind).toBe(1059);
});
});
describe("unwrapGiftWrap", () => {
const senderSkBytes = hexToBytes(TEST_SENDER_SK);
const senderPk = getPublicKey(senderSkBytes);
const recipientSkBytes = hexToBytes(TEST_RECIPIENT_SK);
const recipientPk = getPublicKey(recipientSkBytes);
it("unwraps a gift-wrapped message", () => {
const message = "Test message for NIP-17";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped).not.toBeNull();
expect(unwrapped?.content).toBe(message);
expect(unwrapped?.senderPubkey).toBe(senderPk);
});
it("returns sender npub", () => {
const { event } = createGiftWrap(recipientPk, "Hello", senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.senderNpub).toMatch(/^npub1/);
});
it("returns event ID", () => {
const { event, eventId } = createGiftWrap(recipientPk, "Hello", senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.eventId).toBe(eventId);
});
it("fails to unwrap with wrong key", () => {
const message = "Secret message";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const wrongKey = hexToBytes("1111111111111111111111111111111111111111111111111111111111111111");
const unwrapped = unwrapGiftWrap(event, wrongKey);
expect(unwrapped).toBeNull();
});
it("returns null for non-gift-wrap events", () => {
const fakeEvent = {
kind: 1, // Wrong kind
pubkey: "abc",
content: "test",
tags: [],
created_at: 123,
id: "xxx",
sig: "yyy",
};
const result = unwrapGiftWrap(
fakeEvent as unknown as import("nostr-tools").Event,
recipientSkBytes,
);
expect(result).toBeNull();
});
it("returns null for kind:4 events", () => {
const fakeEvent = {
kind: 4, // NIP-04, not gift wrap
pubkey: senderPk,
content: "encrypted",
tags: [["p", recipientPk]],
created_at: 123,
id: "xxx",
sig: "yyy",
};
const result = unwrapGiftWrap(
fakeEvent as unknown as import("nostr-tools").Event,
recipientSkBytes,
);
expect(result).toBeNull();
});
it("handles unicode content", () => {
const message = "Hello 👋 世界 🌍 مرحبا";
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(message);
});
it("handles long messages", () => {
const message = "x".repeat(10000);
const { event } = createGiftWrap(recipientPk, message, senderSkBytes);
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(message);
});
});
describe("roundtrip", () => {
it("sender can verify their own message", () => {
const senderSkBytes = hexToBytes(TEST_SENDER_SK);
const recipientSkBytes = hexToBytes(TEST_RECIPIENT_SK);
const recipientPk = getPublicKey(recipientSkBytes);
const originalMessage = "Roundtrip test";
const { event } = createGiftWrap(recipientPk, originalMessage, senderSkBytes);
// Recipient unwraps
const unwrapped = unwrapGiftWrap(event, recipientSkBytes);
expect(unwrapped?.content).toBe(originalMessage);
});
});

View File

@@ -0,0 +1,206 @@
/**
* NIP-17 Gift Wrap Implementation
*
* Message structure:
* - Kind 14 (rumor): Unsigned chat message
* - Kind 13 (seal): Rumor encrypted to recipient, signed by sender
* - Kind 1059 (gift wrap): Seal encrypted with ephemeral key
*
* Benefits over NIP-04:
* - Metadata privacy: sender/recipient hidden from relays
* - Forward secrecy: ephemeral keys for each message
*
* @see https://github.com/nostr-protocol/nips/blob/master/17.md
*/
import { getPublicKey, nip19, nip59, type Event } from "nostr-tools";
// ============================================================================
// Types
// ============================================================================
export interface UnwrappedMessage {
/** Sender's hex pubkey */
senderPubkey: string;
/** Sender's npub */
senderNpub: string;
/** Decrypted message content */
content: string;
/** Original event timestamp (seconds) */
createdAt: number;
/** Gift wrap event ID */
eventId: string;
}
export interface WrapResult {
/** The gift wrap event to publish */
event: Event;
/** Event ID */
eventId: string;
}
// ============================================================================
// Gift Wrap
// ============================================================================
/**
* Create a NIP-17 gift-wrapped message
*
* @param recipientPubkey - Recipient's pubkey (hex or npub)
* @param content - Message content
* @param privateKeyBytes - Sender's private key as Uint8Array
* @returns Gift wrap event ready to publish
*/
export function createGiftWrap(
recipientPubkey: string,
content: string,
privateKeyBytes: Uint8Array,
): WrapResult {
// Normalize recipient pubkey
let targetPubkey = recipientPubkey;
if (recipientPubkey.startsWith("npub1")) {
const decoded = nip19.decode(recipientPubkey);
if (decoded.type !== "npub") {
throw new Error(`Expected npub, got ${decoded.type}`);
}
// decoded.data is a string for npub type
targetPubkey = decoded.data;
}
// Create kind 14 rumor (unsigned chat message)
const rumor = nip59.createRumor(
{
kind: 14,
content,
tags: [["p", targetPubkey]],
},
privateKeyBytes,
);
// Create kind 13 seal (rumor encrypted to recipient, signed by sender)
const seal = nip59.createSeal(rumor, privateKeyBytes, targetPubkey);
// Create kind 1059 gift wrap (seal encrypted with ephemeral key)
const wrap = nip59.createWrap(seal, targetPubkey);
return {
event: wrap,
eventId: wrap.id,
};
}
/**
* Unwrap a NIP-17 gift-wrapped message
*
* @param wrapEvent - Kind 1059 gift wrap event
* @param privateKeyBytes - Recipient's private key as Uint8Array
* @returns Unwrapped message or null if decryption fails
*/
export function unwrapGiftWrap(
wrapEvent: Event,
privateKeyBytes: Uint8Array,
): UnwrappedMessage | null {
if (wrapEvent.kind !== 1059) {
return null;
}
try {
// nip59.unwrapEvent handles both layers (gift wrap → seal → rumor)
const rumor = nip59.unwrapEvent(wrapEvent, privateKeyBytes);
if (!rumor) {
return null;
}
// Accept kind 14 (NIP-17 chat) or kind 4 (legacy DM in gift wrap)
if (rumor.kind !== 14 && rumor.kind !== 4) {
return null;
}
const senderPubkey = rumor.pubkey;
const senderNpub = nip19.npubEncode(senderPubkey);
return {
senderPubkey,
senderNpub,
content: rumor.content,
createdAt: rumor.created_at,
eventId: wrapEvent.id,
};
} catch {
// Decryption failed - not for us or corrupted
return null;
}
}
// ============================================================================
// Utilities
// ============================================================================
/**
* Convert hex string to Uint8Array
*/
export function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
}
/**
* Convert Uint8Array to hex string
*/
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Get public key from private key bytes
*/
export function getPublicKeyFromPrivate(privateKeyBytes: Uint8Array): string {
return getPublicKey(privateKeyBytes);
}
/**
* Normalize a target identifier to hex pubkey
*/
export function normalizeNostrTarget(target: string): string | null {
// Already hex pubkey (64 chars)
if (/^[a-f0-9]{64}$/i.test(target)) {
return target.toLowerCase();
}
// npub format
if (target.startsWith("npub1")) {
try {
const decoded = nip19.decode(target);
if (decoded.type === "npub") {
return decoded.data.toLowerCase();
}
} catch {
return null;
}
}
return null;
}
/**
* Check if a string looks like a Nostr identifier
*/
export function looksLikeNostrId(value: string): boolean {
// npub format
if (value.startsWith("npub1") && value.length === 63) {
return true;
}
// hex pubkey
if (/^[a-f0-9]{64}$/i.test(value)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,257 @@
/**
* Tests for NIP-42 authentication
*/
import { getPublicKey, verifyEvent } from "nostr-tools";
import { describe, it, expect, beforeEach } from "vitest";
import {
createAuthEvent,
parseAuthChallenge,
createAuthMessage,
createAuthHandler,
isAuthChallenge,
isAuthOk,
parseOkResponse,
} from "./nip42.js";
// Test private key
const TEST_SK_HEX = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const TEST_SK_BYTES = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
TEST_SK_BYTES[i] = parseInt(TEST_SK_HEX.slice(i * 2, i * 2 + 2), 16);
}
const TEST_PK = getPublicKey(TEST_SK_BYTES);
describe("createAuthEvent", () => {
const challenge = "test-challenge-string-12345";
const relayUrl = "wss://relay.example.com";
it("creates a kind:22242 event", () => {
const { event, eventId } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
expect(event.kind).toBe(22242);
expect(eventId).toBe(event.id);
expect(eventId).toHaveLength(64);
});
it("includes relay tag", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const relayTag = event.tags.find((t) => t[0] === "relay");
expect(relayTag).toBeDefined();
expect(relayTag?.[1]).toBe(relayUrl);
});
it("includes challenge tag", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const challengeTag = event.tags.find((t) => t[0] === "challenge");
expect(challengeTag).toBeDefined();
expect(challengeTag?.[1]).toBe(challenge);
});
it("has empty content", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
expect(event.content).toBe("");
});
it("is signed by the private key", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
expect(event.pubkey).toBe(TEST_PK);
});
it("produces a valid signature", () => {
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
expect(verifyEvent(event)).toBe(true);
});
it("has recent timestamp", () => {
const before = Math.floor(Date.now() / 1000);
const { event } = createAuthEvent(challenge, relayUrl, TEST_SK_BYTES);
const after = Math.floor(Date.now() / 1000);
expect(event.created_at).toBeGreaterThanOrEqual(before);
expect(event.created_at).toBeLessThanOrEqual(after);
});
});
describe("parseAuthChallenge", () => {
const relayUrl = "wss://relay.example.com";
it("parses valid AUTH message", () => {
const message = ["AUTH", "challenge-string-123"];
const result = parseAuthChallenge(message, relayUrl);
expect(result).not.toBeNull();
expect(result?.relay).toBe(relayUrl);
expect(result?.challenge).toBe("challenge-string-123");
});
it("returns null for non-AUTH messages", () => {
expect(parseAuthChallenge(["EVENT", {}], relayUrl)).toBeNull();
expect(parseAuthChallenge(["OK", "id", true], relayUrl)).toBeNull();
expect(parseAuthChallenge(["NOTICE", "message"], relayUrl)).toBeNull();
});
it("returns null for invalid AUTH format", () => {
expect(parseAuthChallenge(["AUTH"], relayUrl)).toBeNull();
expect(parseAuthChallenge(["AUTH", ""], relayUrl)).toBeNull();
expect(parseAuthChallenge(["AUTH", 123], relayUrl)).toBeNull();
expect(parseAuthChallenge(["AUTH", null], relayUrl)).toBeNull();
});
it("returns null for non-array input", () => {
expect(parseAuthChallenge("AUTH" as unknown as unknown[], relayUrl)).toBeNull();
expect(parseAuthChallenge({} as unknown as unknown[], relayUrl)).toBeNull();
expect(parseAuthChallenge(null as unknown as unknown[], relayUrl)).toBeNull();
});
});
describe("createAuthMessage", () => {
it("creates AUTH message array", () => {
const { event } = createAuthEvent("challenge", "wss://relay.test", TEST_SK_BYTES);
const message = createAuthMessage(event);
expect(Array.isArray(message)).toBe(true);
expect(message[0]).toBe("AUTH");
expect(message[1]).toBe(event);
});
});
describe("createAuthHandler", () => {
let handler: ReturnType<typeof createAuthHandler>;
beforeEach(() => {
handler = createAuthHandler(TEST_SK_BYTES);
});
describe("handleChallenge", () => {
it("creates auth response", () => {
const response = handler.handleChallenge("test-challenge", "wss://relay.test");
expect(response.event.kind).toBe(22242);
expect(response.eventId).toHaveLength(64);
});
it("marks relay as requiring auth", () => {
expect(handler.requiresAuth("wss://relay.test")).toBe(false);
handler.handleChallenge("challenge", "wss://relay.test");
expect(handler.requiresAuth("wss://relay.test")).toBe(true);
});
});
describe("signAuthEvent", () => {
it("signs AUTH event templates from nostr-tools", async () => {
const relay = "wss://relay.test";
const challenge = "challenge-123";
const template = {
kind: 22242,
created_at: Math.floor(Date.now() / 1000),
tags: [
["relay", relay],
["challenge", challenge],
],
content: "",
};
const signed = await handler.signAuthEvent(template);
expect(signed.kind).toBe(22242);
expect(signed.pubkey).toBe(TEST_PK);
expect(verifyEvent(signed)).toBe(true);
expect(handler.requiresAuth(relay)).toBe(true);
expect(handler.isAuthenticated(relay)).toBe(true);
});
});
describe("markAuthenticated / isAuthenticated", () => {
it("tracks authenticated relays", () => {
expect(handler.isAuthenticated("wss://relay.test")).toBe(false);
handler.markAuthenticated("wss://relay.test");
expect(handler.isAuthenticated("wss://relay.test")).toBe(true);
});
it("handles multiple relays", () => {
handler.markAuthenticated("wss://relay1.test");
handler.markAuthenticated("wss://relay2.test");
expect(handler.isAuthenticated("wss://relay1.test")).toBe(true);
expect(handler.isAuthenticated("wss://relay2.test")).toBe(true);
expect(handler.isAuthenticated("wss://relay3.test")).toBe(false);
});
});
});
describe("isAuthChallenge", () => {
it("identifies AUTH challenges", () => {
expect(isAuthChallenge(["AUTH", "challenge"])).toBe(true);
expect(isAuthChallenge(["AUTH", "any-string"])).toBe(true);
});
it("rejects non-AUTH messages", () => {
expect(isAuthChallenge(["EVENT", {}])).toBe(false);
expect(isAuthChallenge(["OK", "id", true])).toBe(false);
expect(isAuthChallenge(["NOTICE", "msg"])).toBe(false);
});
it("rejects malformed AUTH", () => {
expect(isAuthChallenge(["AUTH"])).toBe(false);
expect(isAuthChallenge(["AUTH", 123])).toBe(false);
expect(isAuthChallenge(["AUTH", null])).toBe(false);
});
});
describe("isAuthOk", () => {
it("identifies OK responses", () => {
expect(isAuthOk(["OK", "event-id", true])).toBe(true);
expect(isAuthOk(["OK", "event-id", false])).toBe(true);
expect(isAuthOk(["OK", "event-id", true, "message"])).toBe(true);
});
it("rejects non-OK messages", () => {
expect(isAuthOk(["AUTH", "challenge"])).toBe(false);
expect(isAuthOk(["EVENT", {}])).toBe(false);
expect(isAuthOk(["NOTICE", "msg"])).toBe(false);
});
it("rejects malformed OK", () => {
expect(isAuthOk(["OK"])).toBe(false);
expect(isAuthOk(["OK", "id"])).toBe(false);
expect(isAuthOk(["OK", "id", "string"])).toBe(false);
});
});
describe("parseOkResponse", () => {
it("parses successful OK", () => {
const result = parseOkResponse(["OK", "event-123", true, "success"]);
expect(result).not.toBeNull();
expect(result?.eventId).toBe("event-123");
expect(result?.success).toBe(true);
expect(result?.message).toBe("success");
});
it("parses failed OK", () => {
const result = parseOkResponse(["OK", "event-456", false, "auth-required: need AUTH"]);
expect(result).not.toBeNull();
expect(result?.eventId).toBe("event-456");
expect(result?.success).toBe(false);
expect(result?.message).toBe("auth-required: need AUTH");
});
it("handles missing message", () => {
const result = parseOkResponse(["OK", "event-789", true]);
expect(result).not.toBeNull();
expect(result?.message).toBe("");
});
it("returns null for invalid input", () => {
expect(parseOkResponse(["AUTH", "challenge"])).toBeNull();
expect(parseOkResponse(["OK", "id"])).toBeNull();
});
});

View File

@@ -0,0 +1,220 @@
/**
* NIP-42 Authentication
*
* Handles relay authentication for relays that require it.
*
* Flow:
* 1. Relay sends AUTH challenge: ["AUTH", "<challenge>"]
* 2. Client signs kind:22242 event with challenge in tags
* 3. Client sends: ["AUTH", <signed-event>]
* 4. Relay verifies and grants access
*
* @see https://github.com/nostr-protocol/nips/blob/master/42.md
*/
import { finalizeEvent, type Event, type EventTemplate, type VerifiedEvent } from "nostr-tools";
import { makeAuthEvent as nostrToolsMakeAuthEvent } from "nostr-tools/nip42";
// ============================================================================
// Types
// ============================================================================
export interface AuthChallenge {
/** The relay URL that sent the challenge */
relay: string;
/** The challenge string from the relay */
challenge: string;
}
export interface AuthResponse {
/** The signed authentication event */
event: Event;
/** Event ID */
eventId: string;
}
// ============================================================================
// Auth Event Creation
// ============================================================================
/**
* Create a NIP-42 authentication event
*
* Uses nostr-tools' implementation for compatibility.
*
* @param challenge - The challenge string from the relay
* @param relayUrl - The relay URL requesting auth
* @param privateKeyBytes - User's private key as Uint8Array
* @returns Signed kind:22242 event
*/
export function createAuthEvent(
challenge: string,
relayUrl: string,
privateKeyBytes: Uint8Array,
): AuthResponse {
// Use nostr-tools' makeAuthEvent for compatibility
const event = nostrToolsMakeAuthEvent(relayUrl, challenge);
// Sign the event
const signedEvent = finalizeEvent(event, privateKeyBytes);
return {
event: signedEvent,
eventId: signedEvent.id,
};
}
// ============================================================================
// Challenge Parsing
// ============================================================================
/**
* Parse an AUTH message from a relay
*
* @param message - Raw message from relay (parsed JSON)
* @param relayUrl - The relay URL
* @returns AuthChallenge if valid, null otherwise
*/
export function parseAuthChallenge(message: unknown[], relayUrl: string): AuthChallenge | null {
// AUTH message format: ["AUTH", "<challenge>"]
if (!Array.isArray(message)) {
return null;
}
if (message[0] !== "AUTH") {
return null;
}
const challenge = message[1];
if (typeof challenge !== "string" || challenge.length === 0) {
return null;
}
return {
relay: relayUrl,
challenge,
};
}
/**
* Create the AUTH response message to send to relay
*
* @param event - The signed auth event
* @returns Message array to send: ["AUTH", <event>]
*/
export function createAuthMessage(event: Event): unknown[] {
return ["AUTH", event];
}
// ============================================================================
// Auth Handler
// ============================================================================
export interface AuthHandler {
/** Handle an AUTH challenge from a relay */
handleChallenge: (challenge: string, relayUrl: string) => AuthResponse;
/** Sign nostr-tools AUTH template events (SimplePool onauth callback) */
signAuthEvent: (eventTemplate: EventTemplate) => Promise<VerifiedEvent>;
/** Check if a relay requires auth (based on past challenges) */
requiresAuth: (relayUrl: string) => boolean;
/** Mark a relay as authenticated */
markAuthenticated: (relayUrl: string) => void;
/** Check if already authenticated with a relay */
isAuthenticated: (relayUrl: string) => boolean;
}
/**
* Create an auth handler for a specific private key
*
* @param privateKeyBytes - User's private key
* @returns AuthHandler instance
*/
export function createAuthHandler(privateKeyBytes: Uint8Array): AuthHandler {
const challengedRelays = new Set<string>();
const authenticatedRelays = new Set<string>();
return {
handleChallenge(challenge: string, relayUrl: string): AuthResponse {
challengedRelays.add(relayUrl);
return createAuthEvent(challenge, relayUrl, privateKeyBytes);
},
async signAuthEvent(eventTemplate: EventTemplate): Promise<VerifiedEvent> {
let relayUrl: string | null = null;
let challenge: string | null = null;
for (const tag of eventTemplate.tags ?? []) {
if (tag[0] === "relay" && typeof tag[1] === "string" && tag[1].length > 0) {
relayUrl = tag[1];
} else if (tag[0] === "challenge" && typeof tag[1] === "string" && tag[1].length > 0) {
challenge = tag[1];
}
}
if (relayUrl && challenge) {
challengedRelays.add(relayUrl);
}
const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes);
if (relayUrl) {
authenticatedRelays.add(relayUrl);
}
return signedEvent;
},
requiresAuth(relayUrl: string): boolean {
return challengedRelays.has(relayUrl);
},
markAuthenticated(relayUrl: string): void {
authenticatedRelays.add(relayUrl);
},
isAuthenticated(relayUrl: string): boolean {
return authenticatedRelays.has(relayUrl);
},
};
}
// ============================================================================
// Relay Message Types
// ============================================================================
/**
* Check if a relay message is an AUTH challenge
*/
export function isAuthChallenge(message: unknown[]): boolean {
return Array.isArray(message) && message[0] === "AUTH" && typeof message[1] === "string";
}
/**
* Check if a relay message is an OK response to AUTH
*/
export function isAuthOk(message: unknown[]): boolean {
// OK format: ["OK", "<event-id>", true/false, "<message>"]
return (
Array.isArray(message) &&
message[0] === "OK" &&
typeof message[1] === "string" &&
typeof message[2] === "boolean"
);
}
/**
* Parse an OK response
*/
export function parseOkResponse(message: unknown[]): {
eventId: string;
success: boolean;
message: string;
} | null {
if (!isAuthOk(message)) {
return null;
}
return {
eventId: message[1] as string,
success: message[2] as boolean,
message: (message[3] as string) ?? "",
};
}

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { isTruthyEnvValue } from "../../../src/infra/env.js";
import { fetchDmInboxRelays, fetchRelayList, getRelaysForDm } from "./nip65.js";
const LIVE =
isTruthyEnvValue(process.env.NOSTR_LIVE_TEST) ||
isTruthyEnvValue(process.env.LIVE) ||
isTruthyEnvValue(process.env.OPENCLAW_LIVE_TEST);
const describeLive = LIVE ? describe : describe.skip;
const TEST_PUBKEY =
process.env.NOSTR_LIVE_PUBKEY?.trim() ||
"c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
describeLive("NIP-65 live relay discovery", () => {
it("queries public relays and returns sanitized relay lists", async () => {
const dmInboxRelays = await fetchDmInboxRelays(TEST_PUBKEY);
const relayList = await fetchRelayList(TEST_PUBKEY);
const resolvedRelays = await getRelaysForDm(TEST_PUBKEY, ["wss://relay.damus.io"]);
expect(Array.isArray(dmInboxRelays)).toBe(true);
expect(Array.isArray(relayList.read)).toBe(true);
expect(Array.isArray(relayList.write)).toBe(true);
expect(Array.isArray(relayList.all)).toBe(true);
expect(Array.isArray(resolvedRelays)).toBe(true);
for (const relay of [
...dmInboxRelays,
...relayList.read,
...relayList.write,
...relayList.all,
...resolvedRelays,
]) {
expect(relay.startsWith("wss://")).toBe(true);
expect(relay.includes("localhost")).toBe(false);
expect(relay.includes("127.0.0.1")).toBe(false);
}
}, 30_000);
});

View File

@@ -0,0 +1,246 @@
import type { Filter } from "nostr-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
clearRelayCache,
fetchDmInboxRelays,
fetchRelayList,
getRelaysForDm,
sanitizeRelayUrls,
} from "./nip65.js";
interface SubscribeParams {
onevent?: (event: import("nostr-tools").Event) => void;
oneose?: () => void;
onclose?: () => void;
}
class FakePool {
public closeCount = 0;
public destroyCount = 0;
constructor(
private readonly onSubscribe: (
filter: Filter,
params: SubscribeParams,
close: () => void,
) => void,
) {}
subscribeMany(_relays: string[], filter: Filter, params: SubscribeParams): { close: () => void } {
const close = () => {
this.closeCount += 1;
};
this.onSubscribe(filter, params, close);
return { close };
}
destroy(): void {
this.destroyCount += 1;
}
}
const TEST_PUBKEY = "c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
describe("sanitizeRelayUrls", () => {
it("keeps valid public wss relays and deduplicates", () => {
const result = sanitizeRelayUrls([
"wss://relay.damus.io",
"wss://relay.damus.io/",
"wss://relay.primal.net?query=1",
"wss://nos.lol#hash",
]);
expect(result).toEqual(["wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"]);
});
it("drops unsafe and invalid relays", () => {
const result = sanitizeRelayUrls([
"ws://relay.example.com",
"https://relay.example.com",
"wss://localhost:7447",
"wss://127.0.0.1:7447",
"wss://10.0.0.4:7447",
"not-a-url",
"wss://relay.example.com",
]);
expect(result).toEqual(["wss://relay.example.com"]);
});
});
describe("fetchDmInboxRelays", () => {
beforeEach(() => {
clearRelayCache();
});
it("parses kind:10050 relay tags and sanitizes output", async () => {
const pool = new FakePool((_filter, params) => {
setTimeout(() => {
params.onevent?.({
id: "x".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [
["relay", "wss://relay.example.com"],
["relay", "wss://relay.example.com/"],
["relay", "wss://localhost:7447"],
],
content: "",
sig: "y".repeat(128),
} as import("nostr-tools").Event);
params.oneose?.();
}, 0);
});
const relays = await fetchDmInboxRelays(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(relays).toEqual(["wss://relay.example.com"]);
});
it("closes subscriptions when discovery times out", async () => {
vi.useFakeTimers();
const pool = new FakePool(() => {
// Intentionally do nothing so query timeout path is exercised.
});
const pending = fetchDmInboxRelays(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
vi.advanceTimersByTime(5000);
await pending;
expect(pool.closeCount).toBe(1);
vi.useRealTimers();
});
});
describe("fetchRelayList", () => {
beforeEach(() => {
clearRelayCache();
});
it("parses read/write relays from kind:10002 event and sanitizes", async () => {
const pool = new FakePool((_filter, params) => {
setTimeout(() => {
params.onevent?.({
id: "a".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [
["r", "wss://relay-read.example.com", "read"],
["r", "wss://relay-write.example.com", "write"],
["r", "wss://relay-both.example.com"],
["r", "wss://127.0.0.1:7447", "write"],
],
content: "",
sig: "b".repeat(128),
} as import("nostr-tools").Event);
params.oneose?.();
}, 0);
});
const relayList = await fetchRelayList(
TEST_PUBKEY,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(relayList.read).toEqual([
"wss://relay-read.example.com",
"wss://relay-both.example.com",
]);
expect(relayList.write).toEqual([
"wss://relay-write.example.com",
"wss://relay-both.example.com",
]);
expect(relayList.all).toEqual([
"wss://relay-read.example.com",
"wss://relay-write.example.com",
"wss://relay-both.example.com",
]);
});
});
describe("getRelaysForDm", () => {
beforeEach(() => {
clearRelayCache();
});
it("prefers DM inbox relays over write relays", async () => {
const pool = new FakePool((filter, params) => {
setTimeout(() => {
if (filter.kinds?.includes(10050)) {
params.onevent?.({
id: "m".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 2,
tags: [["relay", "wss://relay-dm.example.com"]],
content: "",
sig: "n".repeat(128),
} as import("nostr-tools").Event);
} else if (filter.kinds?.includes(10002)) {
params.onevent?.({
id: "o".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [["r", "wss://relay-write.example.com", "write"]],
content: "",
sig: "p".repeat(128),
} as import("nostr-tools").Event);
}
params.oneose?.();
}, 0);
});
const relays = await getRelaysForDm(
TEST_PUBKEY,
["wss://relay-fallback.example.com"],
pool as unknown as import("nostr-tools").SimplePool,
);
expect(relays).toEqual(["wss://relay-dm.example.com"]);
});
it("falls back to configured relays when discovered relays are unsafe", async () => {
const pool = new FakePool((filter, params) => {
setTimeout(() => {
if (filter.kinds?.includes(10050)) {
params.onevent?.({
id: "q".repeat(64),
kind: 10050,
pubkey: TEST_PUBKEY,
created_at: 2,
tags: [["relay", "wss://localhost:7447"]],
content: "",
sig: "r".repeat(128),
} as import("nostr-tools").Event);
} else if (filter.kinds?.includes(10002)) {
params.onevent?.({
id: "s".repeat(64),
kind: 10002,
pubkey: TEST_PUBKEY,
created_at: 1,
tags: [["r", "wss://127.0.0.1:7447", "write"]],
content: "",
sig: "t".repeat(128),
} as import("nostr-tools").Event);
}
params.oneose?.();
}, 0);
});
const fallback = ["wss://relay-fallback.example.com"];
const relays = await getRelaysForDm(
TEST_PUBKEY,
fallback,
pool as unknown as import("nostr-tools").SimplePool,
);
expect(relays).toEqual(fallback);
});
});

View File

@@ -0,0 +1,398 @@
/**
* NIP-65 Relay List Metadata + NIP-17 DM Inbox Relays
*
* Fetches recipient's preferred relays before sending DMs.
*
* Priority for DM delivery:
* 1. kind:10050 — DM inbox relays (NIP-17 specific)
* 2. kind:10002 — General relay list (write relays)
* 3. Fallback to configured relays
*
* @see https://github.com/nostr-protocol/nips/blob/master/65.md
*/
import { SimplePool, type Filter } from "nostr-tools";
// ============================================================================
// Constants
// ============================================================================
// Bootstrap relays for discovering user relay preferences
// Includes major relays + Primal's premium relay (where Primal publishes)
const BOOTSTRAP_RELAYS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.primal.net",
"wss://premium.primal.net",
"wss://purplepag.es",
];
// Cache TTL (5 minutes)
const CACHE_TTL_MS = 5 * 60 * 1000;
// Query timeout
const QUERY_TIMEOUT_MS = 5000;
// ============================================================================
// Types
// ============================================================================
interface CacheEntry<T> {
data: T;
fetchedAt: number;
}
interface RelayList {
/** Relays for reading (receiving messages) */
read: string[];
/** Relays for writing (publishing messages) */
write: string[];
/** All relays (read + write deduplicated) */
all: string[];
}
function isIpv4Address(hostname: string): boolean {
return /^\d{1,3}(?:\.\d{1,3}){3}$/.test(hostname);
}
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split(".").map((part) => Number.parseInt(part, 10));
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return true;
}
const [a, b] = parts;
if (a === 10 || a === 127 || a === 0) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a >= 224) {
return true;
}
return false;
}
function isPrivateOrLocalHost(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase();
if (!normalized) {
return true;
}
if (normalized === "localhost" || normalized.endsWith(".localhost")) {
return true;
}
if (normalized.includes(":")) {
if (normalized === "::1") {
return true;
}
if (
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
normalized.startsWith("fe80:")
) {
return true;
}
return false;
}
if (isIpv4Address(normalized)) {
return isPrivateIpv4(normalized);
}
return false;
}
function normalizeRelayUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.protocol !== "wss:") {
return null;
}
if (isPrivateOrLocalHost(parsed.hostname)) {
return null;
}
// Normalize for dedupe: drop query/hash and trailing slash.
parsed.search = "";
parsed.hash = "";
const normalized = parsed.toString();
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
} catch {
return null;
}
}
export function sanitizeRelayUrls(relays: string[]): string[] {
const unique = new Set<string>();
for (const relay of relays) {
const normalized = normalizeRelayUrl(relay);
if (!normalized) {
continue;
}
unique.add(normalized);
}
return Array.from(unique);
}
function createScopedPool(pool?: SimplePool): { pool: SimplePool; ownsPool: boolean } {
if (pool) {
return { pool, ownsPool: false };
}
return { pool: new SimplePool(), ownsPool: true };
}
// ============================================================================
// Cache
// ============================================================================
const relayCache = new Map<string, CacheEntry<string[]>>();
const relayListCache = new Map<string, CacheEntry<RelayList>>();
function getCached<T>(cache: Map<string, CacheEntry<T>>, key: string): T | null {
const entry = cache.get(key);
if (entry && Date.now() - entry.fetchedAt < CACHE_TTL_MS) {
return entry.data;
}
return null;
}
function setCache<T>(cache: Map<string, CacheEntry<T>>, key: string, data: T): void {
cache.set(key, { data, fetchedAt: Date.now() });
}
/**
* Clear all relay caches (useful for testing)
*/
export function clearRelayCache(): void {
relayCache.clear();
relayListCache.clear();
}
// ============================================================================
// Relay Discovery
// ============================================================================
/**
* Query relays for events matching a filter
*/
async function queryRelays(
pool: SimplePool,
relays: string[],
filter: Filter,
timeoutMs: number = QUERY_TIMEOUT_MS,
): Promise<import("nostr-tools").Event[]> {
const events: import("nostr-tools").Event[] = [];
return new Promise((resolve) => {
let settled = false;
let sub: { close: () => void } | null = null;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
sub?.close();
resolve(events);
};
const timeout = setTimeout(finish, timeoutMs);
try {
sub = pool.subscribeMany(relays, filter, {
onevent: (event: import("nostr-tools").Event) => {
events.push(event);
},
oneose: finish,
onclose: () => finish(),
});
} catch {
finish();
}
});
}
/**
* Fetch user's DM inbox relays (kind:10050)
*
* These are the relays where the user wants to receive DMs.
*
* @param pubkey - User's hex pubkey
* @param pool - SimplePool instance (optional, creates one if not provided)
* @returns Array of relay URLs
*/
export async function fetchDmInboxRelays(pubkey: string, pool?: SimplePool): Promise<string[]> {
// Check cache
const cacheKey = `dm:${pubkey}`;
const cached = getCached(relayCache, cacheKey);
if (cached) {
return cached;
}
const { pool: usePool, ownsPool } = createScopedPool(pool);
const relayCandidates: string[] = [];
try {
const events = await queryRelays(usePool, BOOTSTRAP_RELAYS, {
kinds: [10050],
authors: [pubkey],
limit: 1,
});
if (events.length > 0) {
// Sort by timestamp, get newest
events.sort((a, b) => b.created_at - a.created_at);
const event = events[0];
// Extract relay URLs from 'relay' tags
for (const tag of event.tags) {
if (tag[0] === "relay" && typeof tag[1] === "string") {
relayCandidates.push(tag[1]);
}
}
}
} catch {
// Ignore errors, return empty
} finally {
if (ownsPool) {
usePool.destroy();
}
}
const relays = sanitizeRelayUrls(relayCandidates);
// Cache result (even if empty)
setCache(relayCache, cacheKey, relays);
return relays;
}
/**
* Fetch user's general relay list (kind:10002)
*
* @param pubkey - User's hex pubkey
* @param pool - SimplePool instance (optional)
* @returns Object with read, write, and all relay arrays
*/
export async function fetchRelayList(pubkey: string, pool?: SimplePool): Promise<RelayList> {
// Check cache
const cacheKey = `list:${pubkey}`;
const cached = getCached(relayListCache, cacheKey);
if (cached) {
return cached;
}
const { pool: usePool, ownsPool } = createScopedPool(pool);
const result: RelayList = { read: [], write: [], all: [] };
const readSet = new Set<string>();
const writeSet = new Set<string>();
const allSet = new Set<string>();
try {
const events = await queryRelays(usePool, BOOTSTRAP_RELAYS, {
kinds: [10002],
authors: [pubkey],
limit: 1,
});
if (events.length > 0) {
// Sort by timestamp, get newest
events.sort((a, b) => b.created_at - a.created_at);
const event = events[0];
for (const tag of event.tags) {
if (tag[0] === "r" && typeof tag[1] === "string") {
const url = normalizeRelayUrl(tag[1]);
if (!url) {
continue;
}
const marker = tag[2];
if (marker === "read") {
readSet.add(url);
} else if (marker === "write") {
writeSet.add(url);
} else {
// No marker = both read and write
readSet.add(url);
writeSet.add(url);
}
allSet.add(url);
}
}
}
} catch {
// Ignore errors, return empty
} finally {
if (ownsPool) {
usePool.destroy();
}
}
result.read = Array.from(readSet);
result.write = Array.from(writeSet);
result.all = Array.from(allSet);
// Cache result
setCache(relayListCache, cacheKey, result);
return result;
}
/**
* Get optimal relays for sending a DM to a pubkey
*
* Priority:
* 1. kind:10050 DM inbox relays (most specific)
* 2. kind:10002 write relays (general preference)
* 3. Fallback relays (configured defaults)
*
* @param recipientPubkey - Recipient's hex pubkey
* @param fallbackRelays - Relays to use if discovery fails
* @param pool - SimplePool instance (optional)
* @returns Array of relay URLs to publish to
*/
export async function getRelaysForDm(
recipientPubkey: string,
fallbackRelays: string[],
pool?: SimplePool,
): Promise<string[]> {
// Try DM inbox first (most specific for NIP-17)
const dmRelays = await fetchDmInboxRelays(recipientPubkey, pool);
if (dmRelays.length > 0) {
return dmRelays;
}
// Try general relay list (write relays)
const relayList = await fetchRelayList(recipientPubkey, pool);
if (relayList.write.length > 0) {
return relayList.write;
}
// Fall back to configured relays
return fallbackRelays;
}
/**
* Get user's write relays (for fetching their events like kind:3)
*
* @param pubkey - User's hex pubkey
* @param fallbackRelays - Relays to use if discovery fails
* @param pool - SimplePool instance (optional)
* @returns Array of relay URLs
*/
export async function getWriteRelays(
pubkey: string,
fallbackRelays: string[],
pool?: SimplePool,
): Promise<string[]> {
const relayList = await fetchRelayList(pubkey, pool);
if (relayList.write.length > 0) {
return relayList.write;
}
return fallbackRelays;
}

View File

@@ -0,0 +1,210 @@
import type { EventTemplate } from "nostr-tools";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
return {
subscribeMany: vi.fn(),
publish: vi.fn(),
getRelaysForDm: vi.fn(),
readNostrBusState: vi.fn(),
writeNostrBusState: vi.fn(),
computeSinceTimestamp: vi.fn(),
readNostrProfileState: vi.fn(),
writeNostrProfileState: vi.fn(),
publishProfile: vi.fn(),
getPublicKey: vi.fn(),
finalizeEvent: vi.fn(),
verifyEvent: vi.fn(),
encrypt: vi.fn(),
decrypt: vi.fn(),
createGiftWrap: vi.fn(),
unwrapGiftWrap: vi.fn(),
};
});
vi.mock("nostr-tools", async (importOriginal) => {
const actual = await importOriginal<typeof import("nostr-tools")>();
class MockSimplePool {
subscribeMany = mocks.subscribeMany;
publish = mocks.publish;
destroy = vi.fn();
}
return {
...actual,
SimplePool: MockSimplePool,
getPublicKey: mocks.getPublicKey,
finalizeEvent: mocks.finalizeEvent,
verifyEvent: mocks.verifyEvent,
};
});
vi.mock("nostr-tools/nip04", () => ({
encrypt: mocks.encrypt,
decrypt: mocks.decrypt,
}));
vi.mock("./nip17.js", () => ({
createGiftWrap: mocks.createGiftWrap,
unwrapGiftWrap: mocks.unwrapGiftWrap,
}));
vi.mock("./nip65.js", () => ({
getRelaysForDm: mocks.getRelaysForDm,
}));
vi.mock("./nostr-state-store.js", () => ({
readNostrBusState: mocks.readNostrBusState,
writeNostrBusState: mocks.writeNostrBusState,
computeSinceTimestamp: mocks.computeSinceTimestamp,
readNostrProfileState: mocks.readNostrProfileState,
writeNostrProfileState: mocks.writeNostrProfileState,
}));
vi.mock("./nostr-profile.js", () => ({
publishProfile: mocks.publishProfile,
}));
import { startNostrBus } from "./nostr-bus.js";
const TEST_HEX_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
const BOT_PUBKEY = "a".repeat(64);
const TARGET_PUBKEY = "b".repeat(64);
function makeSignedEvent(template: EventTemplate) {
return {
...template,
id: "c".repeat(64),
pubkey: BOT_PUBKEY,
sig: "d".repeat(128),
};
}
describe("startNostrBus protocol flow", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getPublicKey.mockReturnValue(BOT_PUBKEY);
mocks.verifyEvent.mockReturnValue(true);
mocks.finalizeEvent.mockImplementation((template: EventTemplate) => makeSignedEvent(template));
mocks.encrypt.mockReturnValue("ciphertext");
mocks.decrypt.mockReturnValue("plaintext");
mocks.createGiftWrap.mockImplementation((toPubkey: string, text: string) => ({
event: {
id: "e".repeat(64),
kind: 1059,
pubkey: "f".repeat(64),
created_at: Math.floor(Date.now() / 1000),
tags: [["p", toPubkey]],
content: `wrapped:${text}`,
sig: "1".repeat(128),
},
eventId: "e".repeat(64),
}));
mocks.unwrapGiftWrap.mockReturnValue(null);
mocks.readNostrBusState.mockResolvedValue(null);
mocks.writeNostrBusState.mockResolvedValue(undefined);
mocks.computeSinceTimestamp.mockReturnValue(0);
mocks.readNostrProfileState.mockResolvedValue(null);
mocks.writeNostrProfileState.mockResolvedValue(undefined);
mocks.publishProfile.mockResolvedValue({
eventId: "p".repeat(64),
createdAt: 1,
successes: [],
failures: [],
});
mocks.getRelaysForDm.mockImplementation(async (_pubkey: string, fallbackRelays: string[]) => {
return fallbackRelays;
});
mocks.subscribeMany.mockImplementation(() => ({
close: vi.fn(),
}));
mocks.publish.mockImplementation(() => [Promise.resolve("ok")]);
});
it("subscribes to both NIP-04 and NIP-17 inbound DM kinds", async () => {
let capturedFilter: unknown;
mocks.subscribeMany.mockImplementation((_relays: string[], filter: unknown) => {
capturedFilter = filter;
return { close: vi.fn() };
});
const bus = await startNostrBus({
privateKey: TEST_HEX_KEY,
relays: ["wss://relay.test"],
onMessage: async () => {},
});
expect(capturedFilter).toEqual({
kinds: [4, 1059],
"#p": [BOT_PUBKEY],
since: 0,
});
bus.close();
});
it("passes an auth signer to publish for auth-required relays", async () => {
const authRelay = "wss://relay-auth.test";
const signedAuthEvents: Array<ReturnType<typeof makeSignedEvent>> = [];
mocks.getRelaysForDm.mockResolvedValue([authRelay]);
mocks.publish.mockImplementation(
(
relays: string[],
event: { kind: number; tags: string[][] },
params: {
onauth?: (template: EventTemplate) => Promise<ReturnType<typeof makeSignedEvent>>;
},
) => {
return [
(async () => {
expect(relays).toEqual([authRelay]);
expect(event.kind).toBe(4);
expect(params.onauth).toBeTypeOf("function");
const signedAuthEvent = await params.onauth!({
kind: 22242,
created_at: 1,
tags: [
["relay", authRelay],
["challenge", "challenge-token"],
],
content: "",
});
signedAuthEvents.push(signedAuthEvent);
return "ok";
})(),
];
},
);
const bus = await startNostrBus({
privateKey: TEST_HEX_KEY,
relays: ["wss://relay-configured.test"],
dmProtocol: "nip04",
onMessage: async () => {},
});
await bus.sendDm(TARGET_PUBKEY, "hello");
expect(mocks.getRelaysForDm).toHaveBeenCalledWith(
TARGET_PUBKEY,
["wss://relay-configured.test"],
expect.anything(),
);
expect(mocks.publish).toHaveBeenCalledTimes(1);
expect(signedAuthEvents).toHaveLength(1);
expect(signedAuthEvents[0].kind).toBe(22242);
expect(signedAuthEvents[0].pubkey).toBe(BOT_PUBKEY);
expect(signedAuthEvents[0].id).toHaveLength(64);
expect(signedAuthEvents[0].sig).toHaveLength(128);
bus.close();
});
});

View File

@@ -1,3 +1,4 @@
import { nip19 } from "nostr-tools";
import { describe, expect, it } from "vitest";
import {
validatePrivateKey,
@@ -157,6 +158,14 @@ describe("normalizePubkey", () => {
expect(() => normalizePubkey("invalid")).toThrow("Pubkey must be 64 hex characters");
});
});
describe("npub format", () => {
it("decodes npub to lowercase hex", () => {
const hex = "c220169537593d7126e9842f31a8d4d5fa66e271ce396f12ddc2d455db855bf2";
const npub = nip19.npubEncode(hex);
expect(normalizePubkey(npub)).toBe(hex);
});
});
});
describe("getPublicKeyFromPrivate", () => {

View File

@@ -6,7 +6,7 @@ import {
nip19,
type Event,
} from "nostr-tools";
import { decrypt, encrypt } from "nostr-tools/nip04";
import { decrypt as nip04Decrypt, encrypt as nip04Encrypt } from "nostr-tools/nip04";
import type { NostrProfile } from "./config-schema.js";
import {
createMetrics,
@@ -15,6 +15,9 @@ import {
type MetricsSnapshot,
type MetricEvent,
} from "./metrics.js";
import { createGiftWrap, unwrapGiftWrap } from "./nip17.js";
import { createAuthHandler } from "./nip42.js";
import { getRelaysForDm } from "./nip65.js";
import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js";
import {
readNostrBusState,
@@ -53,6 +56,12 @@ export interface NostrBusOptions {
relays?: string[];
/** Account ID for state persistence (optional, defaults to pubkey prefix) */
accountId?: string;
/**
* DM protocol version:
* - "nip17" (default): Gift-wrapped messages with metadata privacy
* - "nip04": Legacy encrypted DMs (metadata visible to relays)
*/
dmProtocol?: "nip17" | "nip04";
/** Called when a DM is received */
onMessage: (
pubkey: string,
@@ -203,6 +212,10 @@ interface RelayHealthTracker {
getSortedRelays: (relays: string[]) => string[];
}
type RelayAuthSigner = (
eventTemplate: import("nostr-tools").EventTemplate,
) => Promise<import("nostr-tools").VerifiedEvent>;
function createRelayHealthTracker(): RelayHealthTracker {
const stats = new Map<string, RelayHealthStats>();
@@ -316,12 +329,16 @@ export function getPublicKeyFromPrivate(privateKey: string): string {
// ============================================================================
/**
* Start the Nostr DM bus - subscribes to NIP-04 encrypted DMs
* Start the Nostr DM bus.
*
* Inbound reads both NIP-04 and NIP-17 for compatibility during migration.
* Outbound uses the configured `dmProtocol`.
*/
export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusHandle> {
const {
privateKey,
relays = DEFAULT_RELAYS,
dmProtocol = "nip17",
onMessage,
onError,
onEose,
@@ -330,12 +347,19 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
seenTtlMs = 60 * 60 * 1000,
} = options;
const useNip17 = dmProtocol === "nip17";
const sk = validatePrivateKey(privateKey);
const pk = getPublicKey(sk);
const pool = new SimplePool();
const accountId = options.accountId ?? pk.slice(0, 16);
const gatewayStartedAt = Math.floor(Date.now() / 1000);
// NIP-42 auth handler for relays that require authentication
// SimplePool accepts an onauth callback that signs AUTH events
const authHandler = createAuthHandler(sk);
const onauth: RelayAuthSigner = authHandler.signAuthEvent;
// Initialize metrics
const metrics = onMetric ? createMetrics(onMetric) : createNoopMetrics();
@@ -410,12 +434,6 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
inflight.add(event.id);
// Self-message loop prevention: skip our own messages
if (event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
// Skip events older than our `since` (relay may ignore filter)
if (event.created_at < since) {
metrics.emit("event.rejected.stale");
@@ -436,26 +454,70 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
// Verify signature (must pass before we trust the event)
// Note: For NIP-17, this verifies the gift wrap, not the inner message
if (!verifyEvent(event)) {
metrics.emit("event.rejected.invalid_signature");
onError?.(new Error("Invalid signature"), `event ${event.id}`);
return;
}
if (event.kind !== 4 && event.kind !== 1059) {
metrics.emit("event.rejected.wrong_kind");
return;
}
// For NIP-04, sender pubkey is on outer event.
// For NIP-17, sender pubkey is only available after unwrap.
if (event.kind === 4 && event.pubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
// Mark seen AFTER verify (don't cache invalid IDs)
seen.add(event.id);
metrics.emit("memory.seen_tracker_size", seen.size());
// Decrypt the message
// Decrypt based on event kind.
let plaintext: string;
try {
plaintext = decrypt(sk, event.pubkey, event.content);
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `decrypt from ${event.pubkey}`);
return;
let senderPubkey: string;
if (event.kind === 1059) {
// NIP-17 gift-wrapped message
try {
const unwrapped = unwrapGiftWrap(event, sk);
if (!unwrapped) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
return;
}
plaintext = unwrapped.content;
senderPubkey = unwrapped.senderPubkey;
// Self-message check for NIP-17 (after unwrapping)
if (senderPubkey === pk) {
metrics.emit("event.rejected.self_message");
return;
}
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `unwrap gift wrap ${event.id}`);
return;
}
} else {
// NIP-04 legacy encrypted DM
senderPubkey = event.pubkey;
try {
plaintext = nip04Decrypt(sk, event.pubkey, event.content);
metrics.emit("decrypt.success");
} catch (err) {
metrics.emit("decrypt.failure");
metrics.emit("event.rejected.decrypt_failed");
onError?.(err as Error, `decrypt from ${event.pubkey}`);
return;
}
}
// Create reply function (try relays by health score)
@@ -463,18 +525,20 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
await sendEncryptedDm(
pool,
sk,
event.pubkey,
senderPubkey,
text,
relays,
useNip17,
metrics,
circuitBreakers,
healthTracker,
onError,
onauth,
);
};
// Call the message handler
await onMessage(event.pubkey, plaintext, replyTo);
await onMessage(senderPubkey, plaintext, replyTo);
// Mark as processed
metrics.emit("event.processed");
@@ -488,7 +552,11 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
}
const sub = pool.subscribeMany(relays, [{ kinds: [4], "#p": [pk], since }], {
// Always subscribe to both kinds during migration:
// - kind 4 (legacy NIP-04)
// - kind 1059 (NIP-17 gift wrap)
const dmFilter: import("nostr-tools").Filter = { kinds: [4, 1059], "#p": [pk], since };
const sub = pool.subscribeMany(relays, dmFilter, {
onevent: handleEvent,
oneose: () => {
// EOSE handler - called when all stored events have been received
@@ -505,6 +573,8 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
}
onError?.(new Error(`Subscription closed: ${reason.join(", ")}`), "subscription");
},
// NIP-42: Handle AUTH challenges from relays
onauth,
});
// Public sendDm function
@@ -515,10 +585,12 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
toPubkey,
text,
relays,
useNip17,
metrics,
circuitBreakers,
healthTracker,
onError,
onauth,
);
};
@@ -590,32 +662,64 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
/**
* Send an encrypted DM to a pubkey
*
* Uses NIP-65 to discover recipient's preferred relays before sending.
*/
async function sendEncryptedDm(
pool: SimplePool,
sk: Uint8Array,
toPubkey: string,
text: string,
relays: string[],
configuredRelays: string[],
useNip17: boolean,
metrics: NostrMetrics,
circuitBreakers: Map<string, CircuitBreaker>,
healthTracker: RelayHealthTracker,
onError?: (error: Error, context: string) => void,
onauth?: RelayAuthSigner,
): Promise<void> {
const ciphertext = encrypt(sk, toPubkey, text);
const reply = finalizeEvent(
{
kind: 4,
content: ciphertext,
tags: [["p", toPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
sk,
);
// NIP-65: Discover recipient's preferred relays
let relays: string[];
try {
relays = await getRelaysForDm(toPubkey, configuredRelays, pool);
// Relay discovery completed (relays found: ${relays.length})
} catch (err) {
// Fall back to configured relays on discovery error
relays = configuredRelays;
onError?.(err as Error, "NIP-65 relay discovery");
}
// Create the DM event based on protocol
let dmEvent: Event;
if (useNip17) {
// NIP-17: Gift-wrapped message
const { event } = createGiftWrap(toPubkey, text, sk);
dmEvent = event;
} else {
// NIP-04: Legacy encrypted DM
const ciphertext = nip04Encrypt(sk, toPubkey, text);
dmEvent = finalizeEvent(
{
kind: 4,
content: ciphertext,
tags: [["p", toPubkey]],
created_at: Math.floor(Date.now() / 1000),
},
sk,
);
}
// Sort relays by health score (best first)
const sortedRelays = healthTracker.getSortedRelays(relays);
// Ensure circuit breakers exist for discovered relays
for (const relay of sortedRelays) {
if (!circuitBreakers.has(relay)) {
circuitBreakers.set(relay, createCircuitBreaker(relay, metrics));
}
}
// Try relays in order of health, respecting circuit breakers
let lastError: Error | undefined;
for (const relay of sortedRelays) {
@@ -628,8 +732,7 @@ async function sendEncryptedDm(
const startTime = Date.now();
try {
// oxlint-disable-next-line typescript/await-thenable typesciript/no-floating-promises
await pool.publish([relay], reply);
await Promise.all(pool.publish([relay], dmEvent, { onauth }));
const latency = Date.now() - startTime;
// Record success
@@ -692,10 +795,10 @@ 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)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
if (typeof decoded.data !== "string" || !/^[0-9a-fA-F]{64}$/.test(decoded.data)) {
throw new Error("Invalid npub key");
}
return decoded.data.toLowerCase();
}
// Already hex - validate and return lowercase

View File

@@ -8,6 +8,8 @@ export interface NostrAccountConfig {
name?: string;
privateKey?: string;
relays?: string[];
/** DM protocol: "nip17" (default, gift-wrapped) or "nip04" (legacy) */
dmProtocol?: "nip17" | "nip04";
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
allowFrom?: Array<string | number>;
profile?: NostrProfile;
@@ -21,6 +23,8 @@ export interface ResolvedNostrAccount {
privateKey: string;
publicKey: string;
relays: string[];
/** DM protocol: "nip17" (default) or "nip04" */
dmProtocol: "nip17" | "nip04";
profile?: NostrProfile;
config: NostrAccountConfig;
}
@@ -87,12 +91,14 @@ export function resolveNostrAccount(opts: {
privateKey,
publicKey,
relays: nostrCfg?.relays ?? DEFAULT_RELAYS,
dmProtocol: nostrCfg?.dmProtocol ?? "nip17",
profile: nostrCfg?.profile,
config: {
enabled: nostrCfg?.enabled,
name: nostrCfg?.name,
privateKey: nostrCfg?.privateKey,
relays: nostrCfg?.relays,
dmProtocol: nostrCfg?.dmProtocol,
dmPolicy: nostrCfg?.dmPolicy,
allowFrom: nostrCfg?.allowFrom,
profile: nostrCfg?.profile,