UNPKG

@nostr-dev-kit/ndk

Version:

NDK - Nostr Development Kit. Includes AI Guardrails to catch common mistakes during development.

437 lines (407 loc) 19.5 kB
import type { Debugger } from "debug"; import { GuardrailCheckId } from "../ai-guardrails/index.js"; import type { NDK } from "../ndk/index.js"; import type { NDKFilter } from "../subscription/index.js"; import { isValidHex64 } from "./validation.js"; /** * Filter validation modes for NDK subscriptions. * * @example * ```typescript * const ndk = new NDK({ * filterValidationMode: "validate" // Throw on invalid filters (default) * }); * ``` */ export enum NDKFilterValidationMode { /** * Throw an error when invalid filters are detected (default). * Use this in development to catch filter bugs early. */ VALIDATE = "validate", /** * Automatically fix invalid filters by removing undefined values. * Use this in production for lenient handling of dynamic filters. */ FIX = "fix", /** * Skip validation entirely (legacy behavior). */ IGNORE = "ignore", } /** * Validates or fixes filters based on the specified mode. * * This function checks for: * - Undefined values in arrays * - Invalid data types (non-strings in author/id arrays, non-numbers in kinds) * - Invalid hex strings (authors, ids, #e and #p tags must be 64-char hex) * - Out-of-range kind numbers (must be 0-65535) * * @param filters - The filters to validate or fix * @param mode - The validation mode to use * @param debug - Optional debug instance for logging warnings * @returns Original filters (if valid or ignored) or fixed filters (if fix mode) * @throws Error if validation fails in VALIDATE mode * * @example * ```typescript * // Validate filters (throws on invalid) * const validated = processFilters(filters, NDKFilterValidationMode.VALIDATE); * * // Fix filters (returns cleaned) * const cleaned = processFilters(filters, NDKFilterValidationMode.FIX); * ``` */ export function processFilters( filters: NDKFilter[], mode: NDKFilterValidationMode = NDKFilterValidationMode.VALIDATE, debug?: Debugger, ndk?: NDK, ): NDKFilter[] { if (mode === NDKFilterValidationMode.IGNORE) { return filters; } const issues: string[] = []; const processedFilters = filters.map((filter, index) => { // AI Guardrails - run before standard validation if (ndk?.aiGuardrails.isEnabled()) { runAIGuardrailsForFilter(filter, index, ndk); } const result = processFilter(filter, mode, index, issues, debug); return result; }); if (mode === NDKFilterValidationMode.VALIDATE && issues.length > 0) { throw new Error(`Invalid filter(s) detected:\n${issues.join("\n")}`); } return processedFilters; } /** * Process a single filter - either validate it or fix it */ function processFilter( filter: NDKFilter, mode: NDKFilterValidationMode, filterIndex: number, issues: string[], debug?: Debugger, ): NDKFilter { const isValidating = mode === NDKFilterValidationMode.VALIDATE; const cleanedFilter = isValidating ? filter : { ...filter }; // Process 'ids' array - must be 64-character hex strings if (filter.ids) { const validIds: string[] = []; filter.ids.forEach((id, idx) => { if (id === undefined) { if (isValidating) { issues.push(`Filter[${filterIndex}].ids[${idx}] is undefined`); } else { debug?.(`Fixed: Removed undefined value at ids[${idx}]`); } } else if (typeof id !== "string") { if (isValidating) { issues.push(`Filter[${filterIndex}].ids[${idx}] is not a string (got ${typeof id})`); } else { debug?.(`Fixed: Removed non-string value at ids[${idx}] (was ${typeof id})`); } } else if (!isValidHex64(id)) { if (isValidating) { issues.push(`Filter[${filterIndex}].ids[${idx}] is not a valid 64-char hex string: "${id}"`); } else { debug?.(`Fixed: Removed invalid hex string at ids[${idx}]`); } } else { validIds.push(id); } }); if (!isValidating) { cleanedFilter.ids = validIds.length > 0 ? validIds : undefined; } } // Process 'authors' array - must be 64-character hex strings (pubkeys) if (filter.authors) { const validAuthors: string[] = []; filter.authors.forEach((author, idx) => { if (author === undefined) { if (isValidating) { issues.push(`Filter[${filterIndex}].authors[${idx}] is undefined`); } else { debug?.(`Fixed: Removed undefined value at authors[${idx}]`); } } else if (typeof author !== "string") { if (isValidating) { issues.push(`Filter[${filterIndex}].authors[${idx}] is not a string (got ${typeof author})`); } else { debug?.(`Fixed: Removed non-string value at authors[${idx}] (was ${typeof author})`); } } else if (!isValidHex64(author)) { if (isValidating) { issues.push( `Filter[${filterIndex}].authors[${idx}] is not a valid 64-char hex pubkey: "${author}"`, ); } else { debug?.(`Fixed: Removed invalid hex pubkey at authors[${idx}]`); } } else { validAuthors.push(author); } }); if (!isValidating) { cleanedFilter.authors = validAuthors.length > 0 ? validAuthors : undefined; } } // Process 'kinds' array - must be non-negative integers if (filter.kinds) { const validKinds: number[] = []; filter.kinds.forEach((kind, idx) => { if (kind === undefined) { if (isValidating) { issues.push(`Filter[${filterIndex}].kinds[${idx}] is undefined`); } else { debug?.(`Fixed: Removed undefined value at kinds[${idx}]`); } } else if (typeof kind !== "number") { if (isValidating) { issues.push(`Filter[${filterIndex}].kinds[${idx}] is not a number (got ${typeof kind})`); } else { debug?.(`Fixed: Removed non-number value at kinds[${idx}] (was ${typeof kind})`); } } else if (!Number.isInteger(kind)) { if (isValidating) { issues.push(`Filter[${filterIndex}].kinds[${idx}] is not an integer: ${kind}`); } else { debug?.(`Fixed: Removed non-integer value at kinds[${idx}]: ${kind}`); } } else if (kind < 0 || kind > 65535) { if (isValidating) { issues.push(`Filter[${filterIndex}].kinds[${idx}] is out of valid range (0-65535): ${kind}`); } else { debug?.(`Fixed: Removed out-of-range kind at kinds[${idx}]: ${kind}`); } } else { validKinds.push(kind); } }); if (!isValidating) { cleanedFilter.kinds = validKinds.length > 0 ? validKinds : undefined; } } // Process tag filters (e.g., #e, #p, #t, #a) - values must be strings for (const key in filter) { if (key.startsWith("#") && key.length === 2) { const tagValues = filter[key as `#${string}`]; if (Array.isArray(tagValues)) { const validValues: string[] = []; tagValues.forEach((value, idx) => { if (value === undefined) { if (isValidating) { issues.push(`Filter[${filterIndex}].${key}[${idx}] is undefined`); } else { debug?.(`Fixed: Removed undefined value at ${key}[${idx}]`); } } else if (typeof value !== "string") { if (isValidating) { issues.push(`Filter[${filterIndex}].${key}[${idx}] is not a string (got ${typeof value})`); } else { debug?.(`Fixed: Removed non-string value at ${key}[${idx}] (was ${typeof value})`); } } else { // For #e and #p tags, validate as hex strings if ((key === "#e" || key === "#p") && !isValidHex64(value)) { if (isValidating) { issues.push( `Filter[${filterIndex}].${key}[${idx}] is not a valid 64-char hex string: "${value}"`, ); } else { debug?.(`Fixed: Removed invalid hex string at ${key}[${idx}]`); } } else { validValues.push(value); } } }); if (!isValidating) { cleanedFilter[key as `#${string}`] = validValues.length > 0 ? validValues : undefined; } } } } // Clean up undefined fields in fix mode if (!isValidating) { Object.keys(cleanedFilter).forEach((key) => { if (cleanedFilter[key as keyof NDKFilter] === undefined) { delete cleanedFilter[key as keyof NDKFilter]; } }); } return cleanedFilter; } /** * Run AI Guardrails checks on a filter. * These are educational checks to catch common mistakes. */ function runAIGuardrailsForFilter(filter: NDKFilter, filterIndex: number, ndk: NDK): void { const guards = ndk.aiGuardrails; const filterPreview = JSON.stringify(filter, null, 2); // Check 1: Filter contains only limit if (Object.keys(filter).length === 1 && filter.limit !== undefined) { guards.error( GuardrailCheckId.FILTER_ONLY_LIMIT, `Filter[${filterIndex}] contains only 'limit' without any filtering criteria.\n\n` + `📦 Your filter:\n${filterPreview}\n\n` + `⚠️ This will fetch random events from relays without any criteria.`, `Add filtering criteria:\n ✅ { kinds: [1], limit: 10 }\n ✅ { authors: [pubkey], limit: 10 }\n ❌ { limit: 10 }`, ); } // Check 2: Empty filter if (Object.keys(filter).length === 0) { guards.error( GuardrailCheckId.FILTER_EMPTY, `Filter[${filterIndex}] is empty.\n\n` + `📦 Your filter:\n${filterPreview}\n\n` + `⚠️ This will request ALL events from relays, which is never what you want.`, `Add filtering criteria like 'kinds', 'authors', or tags.`, false, // Fatal error - cannot be disabled ); } // Check 4: since > until if (filter.since !== undefined && filter.until !== undefined && filter.since > filter.until) { const sinceDate = new Date(filter.since * 1000).toISOString(); const untilDate = new Date(filter.until * 1000).toISOString(); guards.error( GuardrailCheckId.FILTER_SINCE_AFTER_UNTIL, `Filter[${filterIndex}] has 'since' AFTER 'until'.\n\n` + `📦 Your filter:\n${filterPreview}\n\n` + `❌ since: ${filter.since} (${sinceDate})\n` + `❌ until: ${filter.until} (${untilDate})\n\n` + `No events can match this time range!`, `'since' must be BEFORE 'until'. Both are Unix timestamps in seconds.`, false, // Fatal error - cannot be disabled ); } // Check 5: Invalid hex strings in filter arrays const bech32Regex = /^n(addr|event|ote|pub|profile)1/; if (filter.ids) { filter.ids.forEach((id, idx) => { if (typeof id === "string") { // Check for bech32 first (more specific error message) if (bech32Regex.test(id)) { guards.error( GuardrailCheckId.FILTER_BECH32_IN_ARRAY, `Filter[${filterIndex}].ids[${idx}] contains bech32: "${id}". IDs must be hex, not bech32.`, `Use filterFromId() to decode bech32 first: import { filterFromId } from "@nostr-dev-kit/ndk"`, false, // Fatal error - cannot be disabled ); } // Then check for any invalid hex string (catches garbage data) else if (!isValidHex64(id)) { guards.error( GuardrailCheckId.FILTER_INVALID_HEX, `Filter[${filterIndex}].ids[${idx}] is not a valid 64-char hex string: "${id}"`, `Event IDs must be 64-character hexadecimal strings. Invalid IDs often come from corrupted data in user-generated lists. Always validate hex strings before using them in filters:\n\n const validIds = ids.filter(id => /^[0-9a-f]{64}$/i.test(id));`, false, // Fatal error - cannot be disabled ); } } }); } if (filter.authors) { filter.authors.forEach((author, idx) => { if (typeof author === "string") { // Check for bech32 first (more specific error message) if (bech32Regex.test(author)) { guards.error( GuardrailCheckId.FILTER_BECH32_IN_ARRAY, `Filter[${filterIndex}].authors[${idx}] contains bech32: "${author}". Authors must be hex pubkeys, not npub.`, `Use ndkUser.pubkey instead. Example: { authors: [ndkUser.pubkey] }`, false, // Fatal error - cannot be disabled ); } // Then check for any invalid hex string (catches garbage data from follow lists) else if (!isValidHex64(author)) { guards.error( GuardrailCheckId.FILTER_INVALID_HEX, `Filter[${filterIndex}].authors[${idx}] is not a valid 64-char hex pubkey: "${author}"`, `Kind:3 follow lists can contain invalid entries like labels ("Follow List"), partial strings ("highlig"), or other corrupted data. You MUST validate all pubkeys before using them in filters.\n\n Example:\n const validPubkeys = pubkeys.filter(p => /^[0-9a-f]{64}$/i.test(p));\n ndk.subscribe({ authors: validPubkeys, kinds: [1] });`, false, // Fatal error - cannot be disabled ); } } }); } // Check 6: Invalid hex strings in tag filters for (const key in filter) { if (key.startsWith("#") && key.length === 2) { const tagValues = filter[key as `#${string}`]; if (Array.isArray(tagValues)) { tagValues.forEach((value, idx) => { if (typeof value === "string") { // For #e and #p tags, validate hex format if (key === "#e" || key === "#p") { // Check for bech32 first (more specific error message) if (bech32Regex.test(value)) { guards.error( GuardrailCheckId.FILTER_BECH32_IN_ARRAY, `Filter[${filterIndex}].${key}[${idx}] contains bech32: "${value}". Tag values must be decoded.`, `Use filterFromId() or nip19.decode() to get the hex value first.`, false, // Fatal error - cannot be disabled ); } // Then check for any invalid hex string else if (!isValidHex64(value)) { guards.error( GuardrailCheckId.FILTER_INVALID_HEX, `Filter[${filterIndex}].${key}[${idx}] is not a valid 64-char hex string: "${value}"`, `${key === "#e" ? "Event IDs" : "Public keys"} in tag filters must be 64-character hexadecimal strings. Kind:3 follow lists and other user-generated content can contain invalid data. Always filter before using:\n\n const validValues = values.filter(v => /^[0-9a-f]{64}$/i.test(v));`, false, // Fatal error - cannot be disabled ); } } } }); } } } // Check 7: Invalid #a tag format and kind if (filter["#a"]) { const aTags = filter["#a"]; aTags?.forEach((aTag, idx) => { if (typeof aTag === "string") { // Check basic format first if (!/^\d+:[0-9a-f]{64}:.*$/.test(aTag)) { guards.error( GuardrailCheckId.FILTER_INVALID_A_TAG, `Filter[${filterIndex}].#a[${idx}] has invalid format: "${aTag}". Must be "kind:pubkey:d-tag".`, `Example: "30023:fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52:my-article"`, false, // Fatal error - cannot be disabled ); } else { // Extract and validate kind is addressable (30000-39999) const kind = Number.parseInt(aTag.split(":")[0], 10); if (kind < 30000 || kind > 39999) { guards.error( GuardrailCheckId.FILTER_INVALID_A_TAG, `Filter[${filterIndex}].#a[${idx}] uses non-addressable kind ${kind}: "${aTag}". #a filters are only for addressable events (kinds 30000-39999).`, `Addressable events include:\n • 30000-30039: Parameterized Replaceable Events (profiles, settings, etc.)\n • 30040-39999: Other addressable events\n\nFor regular events (kind ${kind}), use:\n • #e filter for specific event IDs\n • kinds + authors filters for event queries`, false, // Fatal error - cannot be disabled ); } } } }); } // Check 8: Hashtag filters with # prefix if (filter["#t"]) { const tTags = filter["#t"]; tTags?.forEach((tag, idx) => { if (typeof tag === "string" && tag.startsWith("#")) { guards.error( GuardrailCheckId.FILTER_HASHTAG_WITH_PREFIX, `Filter[${filterIndex}].#t[${idx}] contains hashtag with # prefix: "${tag}". Hashtag values should NOT include the # symbol.`, `Remove the # prefix from hashtag filters:\n ✅ { "#t": ["nostr"] }\n ❌ { "#t": ["#nostr"] }`, false, // Fatal error - cannot be disabled ); } }); } }