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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-18 14:49:37 +01:00
committed by GitHub
parent 4d90aab83c
commit b70eb82fea
8 changed files with 368 additions and 8 deletions

View File

@@ -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) {
))}
</div>
)}
{domainAuthors && domainAuthors.length > 0 && (
<div className="text-xs space-y-0.5 text-muted-foreground">
{domainAuthors.map((domain) => (
<div key={domain}> @{domain}</div>
))}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
@@ -311,6 +327,13 @@ function QueryDropdown({ filter, nip05Authors }: QueryDropdownProps) {
: `Show all ${pTagPubkeys.length}`}
</button>
)}
{domainPTags && domainPTags.length > 0 && (
<div className="text-xs space-y-0.5 text-muted-foreground">
{domainPTags.map((domain) => (
<div key={domain}> @{domain}</div>
))}
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
@@ -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}
/>
)}

View File

@@ -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}
/>
);

View File

@@ -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<string>();
const nip05PTags = new Set<string>();
const nip05PTagsUppercase = new Set<string>();
const domainAuthors = new Set<string>();
const domainPTags = new Set<string>();
const domainPTagsUppercase = new Set<string>();
// Use sets for deduplication during accumulation
const kinds = new Set<number>();
@@ -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,
};
}

View File

@@ -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<string[]> {
// 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<Map<string, string[]>> {
const results = new Map<string, string[]>();
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;
}

View File

@@ -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");

View File

@@ -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<string>();
const nip05PTags = new Set<string>();
const nip05PTagsUppercase = new Set<string>();
const domainAuthors = new Set<string>();
const domainPTags = new Set<string>();
const domainPTagsUppercase = new Set<string>();
// Use sets for deduplication during accumulation
const kinds = new Set<number>();
@@ -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,
};
}

View File

@@ -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<string, ManPageEntry> = {
}
}
// 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<string, ManPageEntry> = {
}
}
// 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;
},
},

View File

@@ -1 +1 @@
{"root":["./vite.config.ts"],"errors":true,"version":"5.9.3"}
{"root":["./vite.config.ts"],"version":"5.6.3"}