From b70eb82fea0480d42c95bf02626f351ba1dc2687 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 18 Jan 2026 14:49:37 +0100 Subject: [PATCH] feat: add @domain alias for NIP-05 domain directory resolution (#136) Add support for @domain syntax in req and count commands to query all users from a domain's NIP-05 directory (e.g., @habla.news). Features: - Fetches /.well-known/nostr.json from domain - Extracts all pubkeys from the names object - Works with -a (authors), -p (#p tags), and -P (#P tags) flags - Supports mixed usage with npub, hex, NIP-05, $me, $contacts - 5-minute caching for domain lookups - UI display in ReqViewer query dropdown Implementation: - Added resolveDomainDirectory and resolveDomainDirectoryBatch to nip05.ts - Updated req-parser and count-parser to detect @domain syntax - Updated argParsers in man.ts to resolve domains asynchronously - Updated ReqViewer to display queried domains in dropdown - Added comprehensive tests for domain resolution Examples: - req -k 1 -a @habla.news - req -k 7 -p @nostr.band - count relay.damus.io -k 1 -a @getcurrent.io Co-authored-by: Claude --- src/components/ReqViewer.tsx | 29 +++++++- src/components/WindowRenderer.tsx | 2 + src/lib/count-parser.ts | 33 ++++++++- src/lib/nip05.ts | 108 ++++++++++++++++++++++++++++++ src/lib/req-parser.test.ts | 73 ++++++++++++++++++++ src/lib/req-parser.ts | 42 ++++++++++-- src/types/man.ts | 87 +++++++++++++++++++++++- tsconfig.node.tsbuildinfo | 2 +- 8 files changed, 368 insertions(+), 8 deletions(-) diff --git a/src/components/ReqViewer.tsx b/src/components/ReqViewer.tsx index 848962b..7aa45ea 100644 --- a/src/components/ReqViewer.tsx +++ b/src/components/ReqViewer.tsx @@ -97,6 +97,8 @@ interface ReqViewerProps { view?: ViewMode; nip05Authors?: string[]; nip05PTags?: string[]; + domainAuthors?: string[]; + domainPTags?: string[]; needsAccount?: boolean; title?: string; } @@ -105,9 +107,16 @@ interface QueryDropdownProps { filter: NostrFilter; nip05Authors?: string[]; nip05PTags?: string[]; + domainAuthors?: string[]; + domainPTags?: string[]; } -function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { +function QueryDropdown({ + filter, + nip05Authors, + domainAuthors, + domainPTags, +}: QueryDropdownProps) { const { copy: handleCopy, copied } = useCopy(); // Expandable lists state @@ -271,6 +280,13 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { ))} )} + {domainAuthors && domainAuthors.length > 0 && ( +
+ {domainAuthors.map((domain) => ( +
→ @{domain}
+ ))} +
+ )} @@ -311,6 +327,13 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) { : `Show all ${pTagPubkeys.length}`} )} + {domainPTags && domainPTags.length > 0 && ( +
+ {domainPTags.map((domain) => ( +
→ @{domain}
+ ))} +
+ )} @@ -610,6 +633,8 @@ export default function ReqViewer({ view = "list", nip05Authors, nip05PTags, + domainAuthors, + domainPTags, needsAccount = false, title = "nostr-events", }: ReqViewerProps) { @@ -1203,6 +1228,8 @@ export default function ReqViewer({ filter={resolvedFilter} nip05Authors={nip05Authors} nip05PTags={nip05PTags} + domainAuthors={domainAuthors} + domainPTags={domainPTags} /> )} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..6ec02c5 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -154,6 +154,8 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { view={window.props.view} nip05Authors={window.props.nip05Authors} nip05PTags={window.props.nip05PTags} + domainAuthors={window.props.domainAuthors} + domainPTags={window.props.domainPTags} needsAccount={window.props.needsAccount} /> ); diff --git a/src/lib/count-parser.ts b/src/lib/count-parser.ts index e8b5c50..35a91ec 100644 --- a/src/lib/count-parser.ts +++ b/src/lib/count-parser.ts @@ -1,6 +1,6 @@ import { nip19 } from "nostr-tools"; import type { NostrFilter } from "@/types/nostr"; -import { isNip05 } from "./nip05"; +import { isNip05, isDomain } from "./nip05"; import { isValidHexPubkey, isValidHexEventId, @@ -14,6 +14,9 @@ export interface ParsedCountCommand { nip05Authors?: string[]; nip05PTags?: string[]; nip05PTagsUppercase?: string[]; + domainAuthors?: string[]; + domainPTags?: string[]; + domainPTagsUppercase?: string[]; needsAccount?: boolean; } @@ -61,6 +64,9 @@ export function parseCountCommand(args: string[]): ParsedCountCommand { const nip05Authors = new Set(); const nip05PTags = new Set(); const nip05PTagsUppercase = new Set(); + const domainAuthors = new Set(); + const domainPTags = new Set(); + const domainPTagsUppercase = new Set(); // Use sets for deduplication during accumulation const kinds = new Set(); @@ -133,6 +139,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand { if (normalized === "$me" || normalized === "$contacts") { authors.add(normalized); addedAny = true; + } else if (authorStr.startsWith("@")) { + const domain = authorStr.slice(1); + if (isDomain(domain)) { + domainAuthors.add(domain); + addedAny = true; + } } else if (isNip05(authorStr)) { nip05Authors.add(authorStr); addedAny = true; @@ -198,6 +210,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand { if (normalized === "$me" || normalized === "$contacts") { pTags.add(normalized); addedAny = true; + } else if (pubkeyStr.startsWith("@")) { + const domain = pubkeyStr.slice(1); + if (isDomain(domain)) { + domainPTags.add(domain); + addedAny = true; + } } else if (isNip05(pubkeyStr)) { nip05PTags.add(pubkeyStr); addedAny = true; @@ -229,6 +247,12 @@ export function parseCountCommand(args: string[]): ParsedCountCommand { if (normalized === "$me" || normalized === "$contacts") { pTagsUppercase.add(normalized); addedAny = true; + } else if (pubkeyStr.startsWith("@")) { + const domain = pubkeyStr.slice(1); + if (isDomain(domain)) { + domainPTagsUppercase.add(domain); + addedAny = true; + } } else if (isNip05(pubkeyStr)) { nip05PTagsUppercase.add(pubkeyStr); addedAny = true; @@ -377,6 +401,13 @@ export function parseCountCommand(args: string[]): ParsedCountCommand { nip05PTagsUppercase.size > 0 ? Array.from(nip05PTagsUppercase) : undefined, + domainAuthors: + domainAuthors.size > 0 ? Array.from(domainAuthors) : undefined, + domainPTags: domainPTags.size > 0 ? Array.from(domainPTags) : undefined, + domainPTagsUppercase: + domainPTagsUppercase.size > 0 + ? Array.from(domainPTagsUppercase) + : undefined, needsAccount, }; } diff --git a/src/lib/nip05.ts b/src/lib/nip05.ts index bba5870..5b3848b 100644 --- a/src/lib/nip05.ts +++ b/src/lib/nip05.ts @@ -98,3 +98,111 @@ export async function resolveNip05Batch( return results; } + +/** + * Domain Directory Resolution (@domain syntax) + * Resolves @domain to all pubkeys in domain's NIP-05 directory + */ + +// Cache for domain directory lookups (domain -> {pubkeys, timestamp}) +const domainDirectoryCache = new Map< + string, + { pubkeys: string[]; timestamp: number } +>(); +const DOMAIN_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +/** + * Check if a string looks like a domain (for @domain syntax) + */ +export function isDomain(value: string): boolean { + if (!value) return false; + return /^[a-zA-Z0-9][\w.-]+\.[a-zA-Z]{2,}$/.test(value); +} + +/** + * Fetch all pubkeys from a domain's NIP-05 directory + * @param domain - Domain name (e.g., "habla.news") + * @returns Array of hex pubkeys from the domain's nostr.json + */ +export async function resolveDomainDirectory( + domain: string, +): Promise { + // Normalize domain to lowercase + const normalizedDomain = domain.toLowerCase(); + + // Check cache first + const cached = domainDirectoryCache.get(normalizedDomain); + if (cached && Date.now() - cached.timestamp < DOMAIN_CACHE_TTL) { + console.log(`Domain directory cache hit for @${normalizedDomain}`); + return cached.pubkeys; + } + + try { + const url = `https://${normalizedDomain}/.well-known/nostr.json`; + const response = await fetch(url, { + headers: { Accept: "application/json" }, + signal: AbortSignal.timeout(5000), // 5s timeout + }); + + if (!response.ok) { + console.warn( + `Domain directory fetch failed for @${normalizedDomain}: ${response.status}`, + ); + return []; + } + + const data = await response.json(); + + if (!data.names || typeof data.names !== "object") { + console.warn(`Invalid nostr.json format for @${normalizedDomain}`); + return []; + } + + // Extract all pubkeys from the names object + const pubkeys = Object.values(data.names) + .filter((pk): pk is string => typeof pk === "string") + .map((pk) => pk.toLowerCase()); + + console.log( + `Resolved @${normalizedDomain} → ${pubkeys.length} pubkeys`, + pubkeys.slice(0, 5), + ); + + // Cache the result + domainDirectoryCache.set(normalizedDomain, { + pubkeys, + timestamp: Date.now(), + }); + + return pubkeys; + } catch (error) { + console.warn( + `Domain directory resolution failed for @${normalizedDomain}:`, + error, + ); + return []; + } +} + +/** + * Resolve multiple domain directories in parallel + * @param domains - Array of domain names + * @returns Map of domain -> pubkeys array + */ +export async function resolveDomainDirectoryBatch( + domains: string[], +): Promise> { + const results = new Map(); + + await Promise.all( + domains.map(async (domain) => { + const pubkeys = await resolveDomainDirectory(domain); + if (pubkeys.length > 0) { + // Store with original domain as key + results.set(domain, pubkeys); + } + }), + ); + + return results; +} diff --git a/src/lib/req-parser.test.ts b/src/lib/req-parser.test.ts index 1bef983..b1babf0 100644 --- a/src/lib/req-parser.test.ts +++ b/src/lib/req-parser.test.ts @@ -153,6 +153,45 @@ describe("parseReqCommand", () => { const result = parseReqCommand(["-a", "user@domain.com,user@domain.com"]); expect(result.nip05Authors).toEqual(["user@domain.com"]); }); + + it("should accumulate @domain syntax for async resolution", () => { + const result = parseReqCommand(["-a", "@habla.news"]); + expect(result.domainAuthors).toEqual(["habla.news"]); + expect(result.filter.authors).toBeUndefined(); + }); + + it("should accumulate multiple @domains for async resolution", () => { + const result = parseReqCommand(["-a", "@habla.news,@nostr.com"]); + expect(result.domainAuthors).toEqual(["habla.news", "nostr.com"]); + expect(result.filter.authors).toBeUndefined(); + }); + + it("should handle mixed hex, NIP-05, and @domain", () => { + const hex = "a".repeat(64); + const result = parseReqCommand([ + "-a", + `${hex},user@domain.com,@habla.news`, + ]); + expect(result.filter.authors).toEqual([hex]); + expect(result.nip05Authors).toEqual(["user@domain.com"]); + expect(result.domainAuthors).toEqual(["habla.news"]); + }); + + it("should deduplicate @domain identifiers", () => { + const result = parseReqCommand(["-a", "@habla.news,@habla.news"]); + expect(result.domainAuthors).toEqual(["habla.news"]); + }); + + it("should preserve @domain case (normalization happens in resolution)", () => { + const result = parseReqCommand(["-a", "@Habla.News"]); + expect(result.domainAuthors).toEqual(["Habla.News"]); + }); + + it("should reject invalid @domain formats", () => { + const result = parseReqCommand(["-a", "@invalid"]); + expect(result.domainAuthors).toBeUndefined(); + expect(result.filter.authors).toBeUndefined(); + }); }); describe("event ID flag (-e)", () => { @@ -577,6 +616,23 @@ describe("parseReqCommand", () => { const result = parseReqCommand(["-p", `${hex},${hex}`]); expect(result.filter["#p"]).toEqual([hex]); }); + + it("should accumulate @domain syntax for #p tags", () => { + const result = parseReqCommand(["-p", "@habla.news"]); + expect(result.domainPTags).toEqual(["habla.news"]); + expect(result.filter["#p"]).toBeUndefined(); + }); + + it("should handle mixed hex, NIP-05, and @domain for #p tags", () => { + const hex = "a".repeat(64); + const result = parseReqCommand([ + "-p", + `${hex},user@domain.com,@habla.news`, + ]); + expect(result.filter["#p"]).toEqual([hex]); + expect(result.nip05PTags).toEqual(["user@domain.com"]); + expect(result.domainPTags).toEqual(["habla.news"]); + }); }); describe("uppercase P tag flag (-P)", () => { @@ -641,6 +697,23 @@ describe("parseReqCommand", () => { expect(result.filter["#P"]).toEqual([hex]); }); + it("should accumulate @domain syntax for #P tags", () => { + const result = parseReqCommand(["-P", "@habla.news"]); + expect(result.domainPTagsUppercase).toEqual(["habla.news"]); + expect(result.filter["#P"]).toBeUndefined(); + }); + + it("should handle mixed hex, NIP-05, and @domain for #P tags", () => { + const hex = "a".repeat(64); + const result = parseReqCommand([ + "-P", + `${hex},user@domain.com,@habla.news`, + ]); + expect(result.filter["#P"]).toEqual([hex]); + expect(result.nip05PTagsUppercase).toEqual(["user@domain.com"]); + expect(result.domainPTagsUppercase).toEqual(["habla.news"]); + }); + it("should handle $me alias in #P tags", () => { const result = parseReqCommand(["-P", "$me"]); expect(result.filter["#P"]).toContain("$me"); diff --git a/src/lib/req-parser.ts b/src/lib/req-parser.ts index 70aa3f6..df9eb1a 100644 --- a/src/lib/req-parser.ts +++ b/src/lib/req-parser.ts @@ -1,6 +1,6 @@ import { nip19 } from "nostr-tools"; import type { NostrFilter } from "@/types/nostr"; -import { isNip05 } from "./nip05"; +import { isNip05, isDomain } from "./nip05"; import { isValidHexPubkey, isValidHexEventId, @@ -18,6 +18,9 @@ export interface ParsedReqCommand { nip05Authors?: string[]; // NIP-05 identifiers that need async resolution nip05PTags?: string[]; // NIP-05 identifiers for #p tags that need async resolution nip05PTagsUppercase?: string[]; // NIP-05 identifiers for #P tags that need async resolution + domainAuthors?: string[]; // @domain aliases for authors that need async resolution + domainPTags?: string[]; // @domain aliases for #p tags that need async resolution + domainPTagsUppercase?: string[]; // @domain aliases for #P tags that need async resolution needsAccount?: boolean; // True if filter contains $me or $contacts aliases } @@ -60,6 +63,9 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { const nip05Authors = new Set(); const nip05PTags = new Set(); const nip05PTagsUppercase = new Set(); + const domainAuthors = new Set(); + const domainPTags = new Set(); + const domainPTagsUppercase = new Set(); // Use sets for deduplication during accumulation const kinds = new Set(); @@ -124,7 +130,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { case "-a": case "--author": { - // Support comma-separated authors: -a npub1...,npub2...,user@domain.com,$me,$contacts + // Support comma-separated authors: -a npub1...,npub2...,user@domain.com,@domain.com,$me,$contacts if (!nextArg) { i++; break; @@ -138,6 +144,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { if (normalized === "$me" || normalized === "$contacts") { authors.add(normalized); addedAny = true; + } else if (authorStr.startsWith("@")) { + // Check for @domain syntax + const domain = authorStr.slice(1); + if (isDomain(domain)) { + domainAuthors.add(domain); + addedAny = true; + } } else if (isNip05(authorStr)) { // Check if it's a NIP-05 identifier nip05Authors.add(authorStr); @@ -208,7 +221,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { } case "-p": { - // Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com,$me,$contacts + // Support comma-separated pubkeys: -p npub1...,npub2...,user@domain.com,@domain.com,$me,$contacts if (!nextArg) { i++; break; @@ -222,6 +235,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { if (normalized === "$me" || normalized === "$contacts") { pTags.add(normalized); addedAny = true; + } else if (pubkeyStr.startsWith("@")) { + // Check for @domain syntax + const domain = pubkeyStr.slice(1); + if (isDomain(domain)) { + domainPTags.add(domain); + addedAny = true; + } } else if (isNip05(pubkeyStr)) { // Check if it's a NIP-05 identifier nip05PTags.add(pubkeyStr); @@ -244,7 +264,7 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { case "-P": { // Uppercase P tag (e.g., zap sender in kind 9735) - // Support comma-separated pubkeys: -P npub1...,npub2...,$me,$contacts + // Support comma-separated pubkeys: -P npub1...,npub2...,@domain.com,$me,$contacts if (!nextArg) { i++; break; @@ -258,6 +278,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { if (normalized === "$me" || normalized === "$contacts") { pTagsUppercase.add(normalized); addedAny = true; + } else if (pubkeyStr.startsWith("@")) { + // Check for @domain syntax + const domain = pubkeyStr.slice(1); + if (isDomain(domain)) { + domainPTagsUppercase.add(domain); + addedAny = true; + } } else if (isNip05(pubkeyStr)) { // Check if it's a NIP-05 identifier nip05PTagsUppercase.add(pubkeyStr); @@ -439,6 +466,13 @@ export function parseReqCommand(args: string[]): ParsedReqCommand { nip05PTagsUppercase.size > 0 ? Array.from(nip05PTagsUppercase) : undefined, + domainAuthors: + domainAuthors.size > 0 ? Array.from(domainAuthors) : undefined, + domainPTags: domainPTags.size > 0 ? Array.from(domainPTags) : undefined, + domainPTagsUppercase: + domainPTagsUppercase.size > 0 + ? Array.from(domainPTagsUppercase) + : undefined, needsAccount, }; } diff --git a/src/types/man.ts b/src/types/man.ts index c566eed..ec0fa8b 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -5,7 +5,7 @@ import type { AppId } from "./app"; import { parseOpenCommand } from "@/lib/open-parser"; import { parseProfileCommand } from "@/lib/profile-parser"; import { parseRelayCommand } from "@/lib/relay-parser"; -import { resolveNip05Batch } from "@/lib/nip05"; +import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05"; import { parseChatCommand } from "@/lib/chat-parser"; import { parseBlossomCommand } from "@/lib/blossom-parser"; @@ -318,6 +318,50 @@ export const manPages: Record = { } } + // Resolve domain directories if present + const allDomains = [ + ...(parsed.domainAuthors || []), + ...(parsed.domainPTags || []), + ...(parsed.domainPTagsUppercase || []), + ]; + + if (allDomains.length > 0) { + const resolved = await resolveDomainDirectoryBatch(allDomains); + + // Add resolved authors to filter + if (parsed.domainAuthors) { + for (const domain of parsed.domainAuthors) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter.authors) parsed.filter.authors = []; + parsed.filter.authors.push(...pubkeys); + } + } + } + + // Add resolved #p tags to filter + if (parsed.domainPTags) { + for (const domain of parsed.domainPTags) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter["#p"]) parsed.filter["#p"] = []; + parsed.filter["#p"].push(...pubkeys); + } + } + } + + // Add resolved #P tags to filter + if (parsed.domainPTagsUppercase) { + for (const domain of parsed.domainPTagsUppercase) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter["#P"]) parsed.filter["#P"] = []; + parsed.filter["#P"].push(...pubkeys); + } + } + } + } + return parsed; }, defaultProps: { filter: { kinds: [1], limit: 50 } }, @@ -443,6 +487,47 @@ export const manPages: Record = { } } + // Resolve domain directories if present + const allDomains = [ + ...(parsed.domainAuthors || []), + ...(parsed.domainPTags || []), + ...(parsed.domainPTagsUppercase || []), + ]; + + if (allDomains.length > 0) { + const resolved = await resolveDomainDirectoryBatch(allDomains); + + if (parsed.domainAuthors) { + for (const domain of parsed.domainAuthors) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter.authors) parsed.filter.authors = []; + parsed.filter.authors.push(...pubkeys); + } + } + } + + if (parsed.domainPTags) { + for (const domain of parsed.domainPTags) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter["#p"]) parsed.filter["#p"] = []; + parsed.filter["#p"].push(...pubkeys); + } + } + } + + if (parsed.domainPTagsUppercase) { + for (const domain of parsed.domainPTagsUppercase) { + const pubkeys = resolved.get(domain); + if (pubkeys) { + if (!parsed.filter["#P"]) parsed.filter["#P"] = []; + parsed.filter["#P"].push(...pubkeys); + } + } + } + } + return parsed; }, }, diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo index 5e39d3d..75ea001 100644 --- a/tsconfig.node.tsbuildinfo +++ b/tsconfig.node.tsbuildinfo @@ -1 +1 @@ -{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"} \ No newline at end of file +{"root":["./vite.config.ts"],"version":"5.6.3"} \ No newline at end of file