From c28dc90760f3cb2b6bdf833a3b1e70bee91b95ac Mon Sep 17 00:00:00 2001 From: lean <1337lean@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:33:26 -0400 Subject: [PATCH] Add diagnostic tool workflows and discovery launcher - Add DNS resolver, email DNS, and security header diagnostics - Wire new tool request parsing, rate limiting, and result UI - Add launcher, related-tool navigation, and diagnostic styling --- app/api/_lib/diagnostic-types.ts | 71 ++++++++ app/api/_lib/dns-diagnostics.ts | 262 +++++++++++++++++++++++++++ app/api/_lib/http-security.ts | 117 ++++++++++++ app/api/tools/[slug]/route.ts | 93 +++++++++- app/globals.css | 88 ++++++++- app/page.tsx | 3 + app/tools/[slug]/page.tsx | 8 +- components/landing/SiteChrome.tsx | 2 + components/tools/DiagnosticTools.tsx | 218 ++++++++++++++++++++++ components/tools/ToolDiscovery.tsx | 193 ++++++++++++++++++++ components/tools/ToolExperience.tsx | 12 +- components/tools/ToolLayout.tsx | 8 +- data/tools.ts | 91 ++++++++-- lib/tool-discovery.ts | 99 ++++++++++ tests/dns-diagnostics.test.ts | 61 +++++++ tests/http-security.test.ts | 57 ++++++ tests/request-options.test.ts | 29 +++ tests/tool-discovery.test.ts | 83 +++++++++ 18 files changed, 1462 insertions(+), 33 deletions(-) create mode 100644 app/api/_lib/diagnostic-types.ts create mode 100644 app/api/_lib/dns-diagnostics.ts create mode 100644 app/api/_lib/http-security.ts create mode 100644 components/tools/DiagnosticTools.tsx create mode 100644 components/tools/ToolDiscovery.tsx create mode 100644 lib/tool-discovery.ts create mode 100644 tests/dns-diagnostics.test.ts create mode 100644 tests/http-security.test.ts create mode 100644 tests/request-options.test.ts create mode 100644 tests/tool-discovery.test.ts diff --git a/app/api/_lib/diagnostic-types.ts b/app/api/_lib/diagnostic-types.ts new file mode 100644 index 0000000..5f71d8d --- /dev/null +++ b/app/api/_lib/diagnostic-types.ts @@ -0,0 +1,71 @@ +export const DNS_RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "NS", "TXT", "CAA"] as const; + +export type DnsRecordType = (typeof DNS_RECORD_TYPES)[number]; + +export type ToolRequest = { + input: string; + options?: { + recordType?: DnsRecordType; + dkimSelector?: string; + }; +}; + +export type DiagnosticStatus = "pass" | "warning" | "fail" | "info" | "error"; + +export type DiagnosticCheck = { + id: string; + label: string; + status: DiagnosticStatus; + summary: string; + records?: string[]; + observedValue?: string; + recommendation?: string; +}; + +export type EmailDnsHealthResult = { + domain: string; + summary: Record; + checks: DiagnosticCheck[]; +}; + +export type ResolverStatus = "match" | "different" | "no-answer" | "error"; + +export type ResolverComparisonResult = { + domain: string; + recordType: DnsRecordType; + summary: { + allSuccessfulAnswersAgree: boolean; + uniqueAnswerSets: number; + successfulResolvers: number; + totalResolvers: number; + }; + resolvers: Array<{ + id: string; + name: string; + address: string; + latencyMs: number; + status: ResolverStatus; + answers: string[]; + error?: string; + }>; +}; + +export type HttpSecurityResult = { + requestedUrl: string; + finalUrl: string; + statusCode: number; + redirectCount: number; + summary: Record; + checks: DiagnosticCheck[]; + headers: Record; +}; + +export function emptyDiagnosticSummary(): Record { + return { pass: 0, warning: 0, fail: 0, info: 0, error: 0 }; +} + +export function summarizeChecks(checks: DiagnosticCheck[]) { + const summary = emptyDiagnosticSummary(); + for (const check of checks) summary[check.status] += 1; + return summary; +} diff --git a/app/api/_lib/dns-diagnostics.ts b/app/api/_lib/dns-diagnostics.ts new file mode 100644 index 0000000..29c0a14 --- /dev/null +++ b/app/api/_lib/dns-diagnostics.ts @@ -0,0 +1,262 @@ +import { Resolver } from "node:dns/promises"; +import { withCache } from "./request-cache"; +import type { + DiagnosticCheck, + DnsRecordType, + EmailDnsHealthResult, + ResolverComparisonResult +} from "./diagnostic-types"; +import { summarizeChecks } from "./diagnostic-types"; + +export const PUBLIC_RESOLVERS = [ + { id: "cloudflare", name: "Cloudflare", address: "1.1.1.1" }, + { id: "google", name: "Google", address: "8.8.8.8" }, + { id: "quad9", name: "Quad9", address: "9.9.9.9" }, + { id: "opendns", name: "OpenDNS", address: "208.67.222.222" } +] as const; + +const RESOLVER_CACHE_TTL_MS = 30_000; +const NO_ANSWER_CODES = new Set(["ENODATA", "ENOTFOUND", "ENOENT"]); + +type QueryOutcome = { answers: string[]; latencyMs: number; error?: string; noAnswer?: boolean }; +type TxtOutcome = { records: string[]; error?: string; noAnswer?: boolean }; + +export type ResolverQuery = ( + resolver: (typeof PUBLIC_RESOLVERS)[number], + domain: string, + recordType: DnsRecordType +) => Promise; + +export async function compareDnsResolvers( + domain: string, + recordType: DnsRecordType, + query: ResolverQuery = queryPublicResolver +): Promise { + return withCache(`resolver-comparison:${domain}:${recordType}`, RESOLVER_CACHE_TTL_MS, async () => { + const outcomes = await Promise.all(PUBLIC_RESOLVERS.map(async (resolver) => { + try { + return { resolver, ...(await query(resolver, domain, recordType)) }; + } catch (error) { + return { + resolver, + answers: [], + latencyMs: 3_000, + error: dnsErrorMessage(error) + }; + } + })); + + const successful = outcomes.filter((outcome) => !outcome.error && !outcome.noAnswer && outcome.answers.length > 0); + const answerSetCounts = new Map(); + + for (const outcome of successful) { + const key = normalizeResolverAnswers(outcome.answers).join("\n"); + answerSetCounts.set(key, (answerSetCounts.get(key) ?? 0) + 1); + } + + const consensusKey = Array.from(answerSetCounts.entries()) + .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))[0]?.[0]; + + return { + domain, + recordType, + summary: { + allSuccessfulAnswersAgree: successful.length > 0 && answerSetCounts.size <= 1, + uniqueAnswerSets: answerSetCounts.size, + successfulResolvers: successful.length, + totalResolvers: PUBLIC_RESOLVERS.length + }, + resolvers: outcomes.map((outcome) => { + const answers = normalizeResolverAnswers(outcome.answers); + const answerKey = answers.join("\n"); + const status = outcome.error + ? "error" as const + : outcome.noAnswer || answers.length === 0 + ? "no-answer" as const + : answerKey === consensusKey + ? "match" as const + : "different" as const; + + return { + ...outcome.resolver, + latencyMs: Math.round(outcome.latencyMs), + status, + answers, + ...(outcome.error ? { error: outcome.error } : {}) + }; + }) + }; + }); +} + +async function queryPublicResolver( + resolverDefinition: (typeof PUBLIC_RESOLVERS)[number], + domain: string, + recordType: DnsRecordType +): Promise { + const resolver = new Resolver({ timeout: 3_000, tries: 1 }); + resolver.setServers([resolverDefinition.address]); + const started = performance.now(); + + try { + const records = await resolveByType(resolver, domain, recordType); + return { answers: normalizeDnsRecords(recordType, records), latencyMs: performance.now() - started }; + } catch (error) { + const code = dnsErrorCode(error); + return { + answers: [], + latencyMs: performance.now() - started, + ...(NO_ANSWER_CODES.has(code) ? { noAnswer: true } : { error: dnsErrorMessage(error) }) + }; + } +} + +async function resolveByType(resolver: Resolver, domain: string, recordType: DnsRecordType): Promise { + switch (recordType) { + case "A": return resolver.resolve4(domain); + case "AAAA": return resolver.resolve6(domain); + case "CNAME": return resolver.resolveCname(domain); + case "MX": return resolver.resolveMx(domain); + case "NS": return resolver.resolveNs(domain); + case "TXT": return resolver.resolveTxt(domain); + case "CAA": return resolver.resolveCaa(domain); + } +} + +export function normalizeDnsRecords(recordType: DnsRecordType, records: unknown[]): string[] { + return normalizeResolverAnswers(records.flatMap((record) => { + if (typeof record === "string") { + return [recordType === "CNAME" || recordType === "NS" ? record.toLowerCase().replace(/\.$/, "") : record]; + } + if (Array.isArray(record)) return [record.map(String).join("")]; + if (!isRecord(record)) return []; + + if (recordType === "MX" && typeof record.exchange === "string") { + return [`${Number(record.priority) || 0} ${record.exchange.toLowerCase().replace(/\.$/, "")}`]; + } + + if (recordType === "CAA") { + const tag = ["issue", "issuewild", "iodef", "tag"].find((key) => typeof record[key] === "string"); + const value = tag ? record[tag] : record.value; + if (tag && typeof value === "string") return [`${Number(record.critical) || 0} ${tag} ${value}`]; + } + + return [stableStringify(record)]; + })); +} + +export function normalizeResolverAnswers(answers: string[]): string[] { + return Array.from(new Set(answers.map((answer) => answer.trim()).filter(Boolean))).sort((left, right) => left.localeCompare(right)); +} + +export async function checkEmailDnsHealth( + domain: string, + dkimSelector?: string, + queryTxt: (name: string) => Promise = querySystemTxt, + queryMx: (name: string) => Promise = querySystemMx +): Promise { + const [mx, spf, dmarc, dkim, mtaSts, tlsReporting] = await Promise.all([ + queryMx(domain), + queryTxt(domain), + queryTxt(`_dmarc.${domain}`), + dkimSelector ? queryTxt(`${dkimSelector}._domainkey.${domain}`) : Promise.resolve({ records: [] }), + queryTxt(`_mta-sts.${domain}`), + queryTxt(`_smtp._tls.${domain}`) + ]); + + const checks = [ + evaluateMx(mx), + evaluateSpf(spf), + evaluateDmarc(dmarc), + evaluateDkim(dkim, dkimSelector), + evaluateOptionalPolicy("mta-sts", "MTA-STS", mtaSts, /^v=STSv1(?:;|\s|$)/i), + evaluateOptionalPolicy("tls-reporting", "TLS reporting", tlsReporting, /^v=TLSRPTv1(?:;|\s|$)/i) + ]; + + return { domain, summary: summarizeChecks(checks), checks }; +} + +export function evaluateMx(outcome: TxtOutcome): DiagnosticCheck { + if (outcome.error) return transportError("mx", "Mail exchangers (MX)", outcome.error); + if (!outcome.records.length) return { id: "mx", label: "Mail exchangers (MX)", status: "fail", summary: "No MX record is published." }; + return { id: "mx", label: "Mail exchangers (MX)", status: "pass", summary: `${outcome.records.length} mail exchanger${outcome.records.length === 1 ? "" : "s"} published.`, records: outcome.records }; +} + +export function evaluateSpf(outcome: TxtOutcome): DiagnosticCheck { + if (outcome.error) return transportError("spf", "SPF policy", outcome.error); + const policies = outcome.records.filter((record) => /^v=spf1(?:\s|$)/i.test(record.trim())); + if (!policies.length) return { id: "spf", label: "SPF policy", status: "fail", summary: "No recognizable v=spf1 policy is published." }; + if (policies.length > 1) return { id: "spf", label: "SPF policy", status: "fail", summary: "Multiple SPF policies are published; receivers cannot reliably evaluate them.", records: policies }; + return { id: "spf", label: "SPF policy", status: "pass", summary: "One recognizable SPF policy is published.", records: policies }; +} + +export function evaluateDmarc(outcome: TxtOutcome): DiagnosticCheck { + if (outcome.error) return transportError("dmarc", "DMARC policy", outcome.error); + const policy = outcome.records.find((record) => /^v=DMARC1(?:;|\s|$)/i.test(record.trim())); + if (!policy) return { id: "dmarc", label: "DMARC policy", status: "fail", summary: "No valid v=DMARC1 policy is published." }; + const disposition = policy.match(/(?:^|;)\s*p\s*=\s*(none|quarantine|reject)(?:;|$)/i)?.[1]?.toLowerCase(); + if (!disposition) return { id: "dmarc", label: "DMARC policy", status: "fail", summary: "The DMARC record has no valid p policy.", records: [policy] }; + if (disposition === "none") return { id: "dmarc", label: "DMARC policy", status: "warning", summary: "DMARC is monitoring only (p=none).", records: [policy] }; + return { id: "dmarc", label: "DMARC policy", status: "pass", summary: `DMARC enforcement is enabled (p=${disposition}).`, records: [policy] }; +} + +export function evaluateDkim(outcome: TxtOutcome, selector?: string): DiagnosticCheck { + if (!selector) return { id: "dkim", label: "DKIM public key", status: "info", summary: "Enter a DKIM selector to check a published public key." }; + if (outcome.error) return transportError("dkim", "DKIM public key", outcome.error); + const valid = outcome.records.find((record) => /^v=DKIM1(?:;|\s|$)/i.test(record.trim()) && /(?:^|;)\s*p\s*=\s*[^;\s]+/i.test(record)); + if (!valid) return { id: "dkim", label: "DKIM public key", status: "fail", summary: `No valid DKIM public-key record was found for selector “${selector}”.`, records: outcome.records.length ? outcome.records : undefined }; + return { id: "dkim", label: "DKIM public key", status: "pass", summary: `A DKIM public key is published for selector “${selector}”.`, records: [valid] }; +} + +function evaluateOptionalPolicy(id: string, label: string, outcome: TxtOutcome, pattern: RegExp): DiagnosticCheck { + if (outcome.error) return transportError(id, label, outcome.error); + const record = outcome.records.find((value) => pattern.test(value.trim())); + return record + ? { id, label, status: "info", summary: `${label} is published as an optional enhancement.`, records: [record] } + : { id, label, status: "info", summary: `${label} is optional and is not currently published.` }; +} + +async function querySystemTxt(name: string): Promise { + const resolver = new Resolver({ timeout: 3_000, tries: 1 }); + try { + return { records: normalizeResolverAnswers((await resolver.resolveTxt(name)).map((record) => record.join(""))) }; + } catch (error) { + return toDnsOutcome(error); + } +} + +async function querySystemMx(name: string): Promise { + const resolver = new Resolver({ timeout: 3_000, tries: 1 }); + try { + return { records: normalizeDnsRecords("MX", await resolver.resolveMx(name)) }; + } catch (error) { + return toDnsOutcome(error); + } +} + +function toDnsOutcome(error: unknown): TxtOutcome { + return NO_ANSWER_CODES.has(dnsErrorCode(error)) + ? { records: [], noAnswer: true } + : { records: [], error: dnsErrorMessage(error) }; +} + +function transportError(id: string, label: string, error: string): DiagnosticCheck { + return { id, label, status: "error", summary: `DNS query failed: ${error}` }; +} + +function dnsErrorCode(error: unknown) { + return isRecord(error) && typeof error.code === "string" ? error.code : "UNKNOWN"; +} + +function dnsErrorMessage(error: unknown) { + const code = dnsErrorCode(error); + return code !== "UNKNOWN" ? code : error instanceof Error ? error.message : "Lookup failed"; +} + +function stableStringify(value: Record) { + return JSON.stringify(Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)))); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/app/api/_lib/http-security.ts b/app/api/_lib/http-security.ts new file mode 100644 index 0000000..14fc995 --- /dev/null +++ b/app/api/_lib/http-security.ts @@ -0,0 +1,117 @@ +import type { DiagnosticCheck, HttpSecurityResult } from "./diagnostic-types"; +import { summarizeChecks } from "./diagnostic-types"; + +type SafeFetch = (url: URL, init: RequestInit, headersOnly?: boolean) => Promise; +type NormalizeUrl = (input: string) => URL; + +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); +const MAX_REDIRECTS = 5; + +export async function inspectHttpSecurity( + input: string, + safeFetch: SafeFetch, + normalizeUrl: NormalizeUrl +): Promise { + const requestedUrl = normalizeUrl(input); + let currentUrl = requestedUrl; + let redirectCount = 0; + let response: Response; + + while (true) { + response = await requestHeadersOnly(currentUrl, safeFetch); + const location = response.headers.get("location"); + + if (!REDIRECT_STATUSES.has(response.status) || !location) break; + if (redirectCount >= MAX_REDIRECTS) throw new Error("Redirect limit exceeded (maximum 5)." ); + + currentUrl = normalizeUrl(new URL(location, currentUrl).href); + redirectCount += 1; + } + + const headers = Object.fromEntries(Array.from(response.headers.entries()).slice(0, 100)); + const checks = evaluateSecurityHeaders(currentUrl, headers); + + return { + requestedUrl: requestedUrl.href, + finalUrl: currentUrl.href, + statusCode: response.status, + redirectCount, + summary: summarizeChecks(checks), + checks, + headers + }; +} + +async function requestHeadersOnly(url: URL, safeFetch: SafeFetch) { + let response = await safeFetch(url, { method: "HEAD" }); + if (response.status === 405 || response.status === 501) { + response = await safeFetch(url, { method: "GET", headers: { Range: "bytes=0-0" } }, true); + } + return response; +} + +export function evaluateSecurityHeaders(finalUrl: URL, headers: Record): DiagnosticCheck[] { + const normalized = Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value.trim()])); + const get = (name: string) => normalized[name.toLowerCase()] || ""; + const csp = get("content-security-policy"); + const frameAncestors = /(?:^|;)\s*frame-ancestors\s+[^;]+/i.test(csp); + const xFrameOptions = /^(deny|sameorigin)$/i.test(get("x-frame-options")); + + return [ + check( + "https", + "HTTPS transport", + finalUrl.protocol === "https:" ? "pass" : "fail", + finalUrl.protocol === "https:" ? "The final response uses HTTPS." : "The final response uses unencrypted HTTP.", + finalUrl.protocol, + "Serve the final destination over HTTPS." + ), + presenceCheck("hsts", "Strict-Transport-Security", get("strict-transport-security"), finalUrl.protocol === "https:" ? "fail" : "warning", "Send HSTS over HTTPS with an appropriate max-age."), + presenceCheck("csp", "Content-Security-Policy", csp, "fail", "Define a site-specific Content-Security-Policy."), + check( + "clickjacking", + "Clickjacking protection", + frameAncestors || xFrameOptions ? "pass" : "fail", + frameAncestors ? "CSP frame-ancestors controls embedding." : xFrameOptions ? "X-Frame-Options controls embedding." : "No frame-ancestors directive or valid X-Frame-Options value was observed.", + frameAncestors ? "CSP frame-ancestors" : get("x-frame-options") || "Not set", + "Prefer CSP frame-ancestors; X-Frame-Options is a compatible fallback." + ), + check( + "nosniff", + "X-Content-Type-Options", + get("x-content-type-options").toLowerCase() === "nosniff" ? "pass" : "fail", + get("x-content-type-options").toLowerCase() === "nosniff" ? "MIME sniffing is disabled." : "The nosniff directive is missing or invalid.", + get("x-content-type-options") || "Not set", + "Set X-Content-Type-Options: nosniff." + ), + presenceCheck("referrer-policy", "Referrer-Policy", get("referrer-policy"), "warning", "Set a privacy-appropriate Referrer-Policy."), + presenceCheck("permissions-policy", "Permissions-Policy", get("permissions-policy"), "warning", "Disable browser capabilities the site does not need."), + presenceCheck("coop", "Cross-Origin-Opener-Policy", get("cross-origin-opener-policy"), "warning", "Consider same-origin where cross-origin window isolation is appropriate."), + presenceCheck("corp", "Cross-Origin-Resource-Policy", get("cross-origin-resource-policy"), "warning", "Set an appropriate cross-origin resource policy for served assets."), + get("cross-origin-embedder-policy") + ? check("coep", "Cross-Origin-Embedder-Policy", "info", "COEP is present; verify that cross-origin isolation is intentional.", get("cross-origin-embedder-policy"), "COEP is optional and can break third-party embeds.") + : check("coep", "Cross-Origin-Embedder-Policy", "info", "COEP is optional and is not set.", "Not set", "Only enable COEP when the site requires cross-origin isolation.") + ]; +} + +function presenceCheck(id: string, label: string, value: string, missingStatus: "warning" | "fail", recommendation: string): DiagnosticCheck { + return check( + id, + label, + value ? "pass" : missingStatus, + value ? `${label} is present.` : `${label} was not observed.`, + value || "Not set", + recommendation + ); +} + +function check( + id: string, + label: string, + status: DiagnosticCheck["status"], + summary: string, + observedValue: string, + recommendation: string +): DiagnosticCheck { + return { id, label, status, summary, observedValue, recommendation }; +} diff --git a/app/api/tools/[slug]/route.ts b/app/api/tools/[slug]/route.ts index c18c0eb..a93cc46 100644 --- a/app/api/tools/[slug]/route.ts +++ b/app/api/tools/[slug]/route.ts @@ -9,6 +9,9 @@ import { getClientIp, trustProxyHeaders } from "../../_lib/client-ip"; import { ConcurrencyLimitError, dedupe, withCache, withConcurrencyLimit } from "../../_lib/request-cache"; import { checkRateLimit } from "../../_lib/rate-limit"; import { isPublicIp, normalizeIpLiteral } from "../../_lib/ip"; +import { checkEmailDnsHealth, compareDnsResolvers } from "../../_lib/dns-diagnostics"; +import { DNS_RECORD_TYPES, type DnsRecordType, type ToolRequest } from "../../_lib/diagnostic-types"; +import { inspectHttpSecurity } from "../../_lib/http-security"; type RouteContext = { params: Promise<{ slug: string }> }; type JsonRecord = Record; @@ -37,7 +40,10 @@ const implementedTools = new Set([ "robots-sitemap", "my-ip", "ip-geolocation", - "asn-lookup" + "asn-lookup", + "dns-resolver-check", + "email-dns-health", + "security-headers" ]); const workerOnlyTools: Record = { @@ -64,12 +70,13 @@ export async function POST(request: NextRequest, context: RouteContext) { enforceDeclaredBodySize(request); const body = await readJsonBody(request); - const input = typeof body.input === "string" ? body.input : ""; + const toolRequest = parseToolRequest(slug, body); + const { input } = toolRequest; const rateLimit = await checkRateLimit(request, { keyPrefix: `tools:${slug}`, - limit: 30, + limit: slug === "dns-resolver-check" || slug === "email-dns-health" ? 15 : 30, windowMs: 60_000, - targetKey: hashKey(input.trim().toLowerCase() || "empty") + targetKey: hashKey(normalizeRateLimitTarget(slug, input)) }); responseHeaders = rateLimit.headers; @@ -86,11 +93,11 @@ export async function POST(request: NextRequest, context: RouteContext) { const execute = () => withConcurrencyLimit(() => workerOnlyTools[slug] ? runWorkerTool(slug, input, requestId) - : runTool(slug, input, request) + : runTool(slug, toolRequest, request) ); const data = requestScopedTools.has(slug) ? await execute() - : await dedupe(`live:${slug}:${hashKey(input)}:${Math.floor(Date.now() / LIVE_DEDUPE_MS)}`, execute); + : await dedupe(makeRequestDedupeKey(slug, toolRequest, Date.now()), execute); return envelope({ data, started, requestId, headers: responseHeaders }); } catch (error) { @@ -238,7 +245,8 @@ function hashKey(value: string) { return createHash("sha256").update(value).digest("hex").slice(0, 32); } -async function runTool(slug: string, input: string, request: NextRequest) { +async function runTool(slug: string, toolRequest: ToolRequest, request: NextRequest) { + const { input, options } = toolRequest; switch (slug) { case "dns-lookup": return lookupDns(input); @@ -262,6 +270,15 @@ async function runTool(slug: string, input: string, request: NextRequest) { return lookupIpNetwork(input); case "asn-lookup": return lookupAsn(input); + case "dns-resolver-check": + return compareDnsResolvers(normalizeHostname(requireInput(input, "Enter a domain name.")), options?.recordType ?? "A"); + case "email-dns-health": + return checkEmailDnsHealth(normalizeHostname(requireInput(input, "Enter a domain name.")), options?.dkimSelector); + case "security-headers": + return inspectHttpSecurity(input, safeFetch, normalizeHttpUrl).catch((error: unknown) => { + if (error instanceof ApiError) throw error; + throw new ApiError(error instanceof Error ? error.message : "Security header inspection failed.", 502); + }); default: throw new ApiError("Unknown tool endpoint.", 404); } @@ -524,6 +541,58 @@ export async function readJsonBody(request: NextRequest) { } } +export function parseToolRequest(slug: string, body: JsonRecord): ToolRequest { + const input = typeof body.input === "string" ? body.input : ""; + const rawOptions = isRecord(body.options) ? body.options : undefined; + + if (slug === "dns-resolver-check") { + if (body.options !== undefined && !rawOptions) throw new ApiError("Options must be a JSON object.", 400); + const rawRecordType = rawOptions?.recordType; + if (rawRecordType !== undefined && (typeof rawRecordType !== "string" || !DNS_RECORD_TYPES.includes(rawRecordType as (typeof DNS_RECORD_TYPES)[number]))) { + throw new ApiError(`Record type must be one of: ${DNS_RECORD_TYPES.join(", ")}.`, 400); + } + return { input, options: { recordType: (rawRecordType as DnsRecordType | undefined) ?? "A" } }; + } + + if (slug === "email-dns-health") { + if (body.options !== undefined && !rawOptions) throw new ApiError("Options must be a JSON object.", 400); + const rawSelector = rawOptions?.dkimSelector; + if (rawSelector !== undefined && typeof rawSelector !== "string") { + throw new ApiError("DKIM selector must be a string.", 400); + } + const dkimSelector = typeof rawSelector === "string" ? rawSelector.trim().toLowerCase() : ""; + if (dkimSelector && !isValidDkimSelector(dkimSelector)) { + throw new ApiError("Enter a valid DKIM selector.", 400); + } + return { input, options: dkimSelector ? { dkimSelector } : {} }; + } + + return { input }; +} + +export function makeRequestDedupeKey(slug: string, request: ToolRequest, now: number) { + const optionKey = JSON.stringify({ + recordType: request.options?.recordType ?? null, + dkimSelector: request.options?.dkimSelector ?? null + }); + return `live:${slug}:${hashKey(`${request.input}\u0000${optionKey}`)}:${Math.floor(now / LIVE_DEDUPE_MS)}`; +} + +function isValidDkimSelector(value: string) { + return value.length <= 253 && value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?$/.test(label)); +} + +function normalizeRateLimitTarget(slug: string, input: string) { + const fallback = input.trim().toLowerCase() || "empty"; + try { + if (["dns-resolver-check", "email-dns-health", "dns-lookup"].includes(slug)) return normalizeHostname(input); + if (["security-headers", "http-headers", "uptime", "redirect-checker", "robots-sitemap"].includes(slug)) return normalizeHttpUrl(input).href; + } catch { + // Invalid targets still receive a stable rate-limit bucket before validation returns an error. + } + return fallback; +} + function envelope(options: { data?: unknown; error?: string; started: number; requestId: string; status?: number; headers?: Record }) { const payload: Envelope = { durationMs: Date.now() - options.started, @@ -688,7 +757,7 @@ function requirePublicIp(input: string) { return raw; } -async function safeFetch(url: URL, init: RequestInit) { +async function safeFetch(url: URL, init: RequestInit, headersOnly = false) { const addresses = await resolvePublicHost(url.hostname); const selected = addresses[0]; const dispatcher = createPinnedDispatcher(selected); @@ -707,6 +776,14 @@ async function safeFetch(url: URL, init: RequestInit) { } as RequestInit & { dispatcher: Dispatcher }); if ((init.method || "GET").toUpperCase() === "HEAD") return response; + if (headersOnly) { + await response.body?.cancel().catch(() => undefined); + return new Response(null, { + status: response.status, + statusText: response.statusText, + headers: response.headers + }); + } const body = await readLimitedText(response, MAX_TEXT_BYTES); return new Response(body, { diff --git a/app/globals.css b/app/globals.css index c9de67b..2f5f444 100644 --- a/app/globals.css +++ b/app/globals.css @@ -57,6 +57,7 @@ a { color: inherit; text-decoration: none; } button, input, textarea, select { font: inherit; } button { color: inherit; } h1, h2, h3, p, dl, dd { margin: 0; } +.sr-only { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; } .skip-link { position: fixed; top: 0.75rem; left: 0.75rem; z-index: 100; padding: 0.65rem 0.8rem; border-radius: 8px; color: #0d0a18; background: var(--purple-bright); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; transform: translateY(-160%); transition: transform 150ms ease; } .skip-link:focus { transform: translateY(0); } @@ -137,6 +138,27 @@ main, .nav-links { display: flex; gap: 2rem; color: var(--muted); font-size: 0.82rem; } .nav-links a, .footer-links a { transition: color 180ms ease; } .nav-links a:hover, .footer-links a:hover { color: var(--text); } +.launcher-trigger { justify-self: end; display: inline-flex; align-items: center; gap: 0.65rem; min-height: 40px; padding: 0 0.55rem 0 0.75rem; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: rgba(255, 255, 255, 0.028); cursor: pointer; font-family: var(--font-mono); font-size: 0.65rem; transition: border-color 160ms ease, color 160ms ease, background 160ms ease; } +.launcher-trigger:hover { border-color: rgba(155, 135, 245, 0.45); color: var(--text); background: rgba(155, 135, 245, 0.07); } +.launcher-icon { color: var(--purple-bright); font-size: 1rem; } +.launcher-trigger kbd { padding: 0.25rem 0.4rem; border: 1px solid var(--line); border-radius: 5px; color: var(--faint); background: rgba(0, 0, 0, 0.2); font: inherit; } +.launcher-backdrop { position: fixed; inset: 0; z-index: 90; display: grid; align-items: start; justify-items: center; padding: max(10vh, 5rem) 1rem 1rem; background: rgba(3, 3, 6, 0.72); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } +.tool-launcher { width: min(660px, 100%); overflow: hidden; border: 1px solid var(--line-strong); border-radius: 14px; background: #0e0e15; box-shadow: 0 40px 120px rgba(0, 0, 0, 0.72), 0 0 80px rgba(116, 87, 231, 0.14); } +.launcher-search-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.8rem; min-height: 64px; padding: 0 0.85rem 0 1rem; border-bottom: 1px solid var(--line); } +.launcher-search-row > span { color: var(--purple-bright); font-size: 1.2rem; } +.launcher-search-row input { min-width: 0; border: 0; outline: 0; color: var(--text); background: transparent; font-family: var(--font-mono); font-size: 0.88rem; } +.launcher-search-row input::placeholder { color: var(--faint); } +.launcher-search-row button { min-height: 32px; padding: 0 0.55rem; border: 1px solid var(--line); border-radius: 6px; color: var(--faint); background: rgba(255, 255, 255, 0.025); cursor: pointer; font-family: var(--font-mono); font-size: 0.58rem; } +.launcher-results { max-height: min(570px, 66vh); padding: 0.65rem; overflow-y: auto; } +.launcher-results > p { padding: 0.3rem 0.45rem 0.55rem; color: var(--faint); font-family: var(--font-mono); font-size: 0.58rem; letter-spacing: 0.08em; text-transform: uppercase; } +.launcher-results > a { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 0.85rem; min-height: 62px; padding: 0.5rem 0.6rem; border: 1px solid transparent; border-radius: 9px; } +.launcher-results > a.is-active { border-color: rgba(155, 135, 245, 0.26); background: rgba(155, 135, 245, 0.08); } +.launcher-results > a > span:nth-child(2) { display: grid; min-width: 0; gap: 0.15rem; } +.launcher-results strong { font-size: 0.78rem; font-weight: 600; } +.launcher-results small { overflow: hidden; color: var(--muted); font-size: 0.68rem; text-overflow: ellipsis; white-space: nowrap; } +.launcher-results em { color: var(--purple-bright); font-style: normal; } +.launcher-empty { padding: 2.5rem 1rem; color: var(--muted); text-align: center; font-size: 0.78rem; } +.tool-launcher footer { display: flex; gap: 1rem; padding: 0.7rem 1rem; border-top: 1px solid var(--line); color: var(--faint); font-family: var(--font-mono); font-size: 0.55rem; } .home-hero { display: grid; @@ -202,6 +224,14 @@ main, .ghost-button { color: var(--muted); border-color: transparent; background: transparent; } .hero-trust { display: flex; flex-wrap: wrap; gap: 1.2rem; margin-top: 1.35rem; color: var(--faint); font-family: var(--font-mono); font-size: 0.63rem; } .hero-trust span::first-letter { color: var(--green); } +.quick-access { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 1rem; padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; background: rgba(155, 135, 245, 0.035); } +.quick-access > span { color: var(--purple); font-family: var(--font-mono); font-size: 0.61rem; letter-spacing: 0.08em; text-transform: uppercase; } +.quick-access > div { display: flex; min-width: 0; gap: 0.45rem; overflow-x: auto; scrollbar-width: none; } +.quick-access > div::-webkit-scrollbar { display: none; } +.quick-access a, .quick-access button { flex: 0 0 auto; min-height: 34px; padding: 0 0.7rem; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: rgba(255, 255, 255, 0.022); cursor: pointer; font-family: var(--font-mono); font-size: 0.61rem; } +.quick-access a { display: inline-flex; align-items: center; } +.quick-access a:hover, .quick-access button:hover { border-color: rgba(155, 135, 245, 0.45); color: var(--purple-bright); } +.quick-access button span { margin-left: 0.4rem; color: var(--faint); } .hero-terminal, .result-panel { @@ -274,7 +304,7 @@ main, display: flex; flex-direction: column; justify-content: space-between; - min-height: 245px; + min-height: 200px; padding: 1.1rem; border: 1px solid var(--line); border-radius: 12px; @@ -290,8 +320,8 @@ main, .command-icon.large { width: 52px; height: 48px; margin-bottom: 1.5rem; font-size: 0.8rem; } .availability { color: #716d7b; font-size: 0.48rem; letter-spacing: 0.07em; text-transform: uppercase; } .availability.is-live { color: var(--green); } -.tool-card h3 { margin-top: 2.3rem; font-size: 0.96rem; letter-spacing: -0.015em; } -.tool-card p { margin-top: 0.6rem; color: var(--muted); font-size: 0.76rem; line-height: 1.6; } +.tool-card h3 { margin-top: 1.25rem; font-size: 0.96rem; letter-spacing: -0.015em; } +.tool-card p { margin-top: 0.5rem; color: var(--muted); font-size: 0.79rem; line-height: 1.55; } .card-link { position: relative; z-index: 1; display: flex; justify-content: space-between; align-items: center; margin-top: 1.5rem; color: #777382; font-size: 0.62rem; transition: color 180ms ease; } .tool-card:hover .card-link { color: var(--purple-bright); } @@ -317,6 +347,12 @@ main, .tool-command span { color: var(--purple); } .tool-command i { display: inline-block; width: 5px; height: 11px; margin-left: 4px; vertical-align: -2px; background: var(--purple); animation: blink 1s steps(2) infinite; } .tool-workspace { display: grid; grid-template-columns: minmax(300px, 0.8fr) minmax(0, 1.2fr); gap: 0.8rem; align-items: stretch; } +.related-tools { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 1rem; margin-top: 0.8rem; padding: 0.85rem 1rem; border: 1px solid var(--line); border-radius: 10px; background: rgba(255, 255, 255, 0.018); } +.related-tools > span { color: var(--faint); font-family: var(--font-mono); font-size: 0.58rem; letter-spacing: 0.06em; text-transform: uppercase; } +.related-tools > div { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.45rem; } +.related-tools a { display: flex; justify-content: space-between; align-items: center; min-width: 0; min-height: 38px; padding: 0 0.7rem; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); font-size: 0.68rem; } +.related-tools a:hover { border-color: rgba(155, 135, 245, 0.4); color: var(--purple-bright); } +.related-tools a span { margin-left: 0.4rem; } .tool-controls, .result-panel { min-height: 390px; } .tool-controls { display: flex; flex-direction: column; gap: 1rem; padding: 1.2rem; border: 1px solid var(--line); border-radius: var(--radius); background: rgba(255, 255, 255, 0.023); } .tool-controls label { display: grid; gap: 0.55rem; } @@ -395,6 +431,30 @@ main, .loading-lines span { display: block; width: 80%; height: 9px; border-radius: 4px; background: linear-gradient(90deg, rgba(155, 135, 245, 0.05), rgba(155, 135, 245, 0.2), rgba(155, 135, 245, 0.05)); background-size: 200% 100%; animation: shimmer 1.2s linear infinite; } .loading-lines span:nth-child(2) { width: 55%; } .loading-lines span:nth-child(3) { width: 68%; } +.status-chip { display: inline-flex; align-items: center; justify-content: center; min-height: 23px; padding: 0 0.45rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); background: rgba(255, 255, 255, 0.025); font-family: var(--font-mono); font-size: 0.54rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; } +.status-chip-pass, .status-chip-match { border-color: rgba(105, 227, 176, 0.28); color: var(--green); background: rgba(105, 227, 176, 0.07); } +.status-chip-warning, .status-chip-different, .status-chip-no-answer { border-color: rgba(243, 187, 107, 0.3); color: var(--amber); background: rgba(243, 187, 107, 0.07); } +.status-chip-fail, .status-chip-error { border-color: rgba(255, 124, 139, 0.3); color: var(--red); background: rgba(255, 124, 139, 0.07); } +.status-chip-info { border-color: rgba(108, 169, 255, 0.3); color: var(--blue); background: rgba(108, 169, 255, 0.07); } +.diagnostic-summary-line { display: flex; align-items: center; flex-wrap: wrap; gap: 0.65rem; color: var(--muted); font-family: var(--font-mono); font-size: 0.65rem; } +.summary-counts { display: flex; flex-wrap: wrap; gap: 0.45rem; } +.summary-counts > span { display: flex; align-items: center; gap: 0.35rem; padding-right: 0.45rem; border: 1px solid var(--line); border-radius: 999px; } +.summary-counts strong { color: var(--text); font-family: var(--font-mono); font-size: 0.62rem; } +.resolver-grid, .diagnostic-checks { display: grid; gap: 0.55rem; } +.diagnostic-row { min-width: 0; padding: 0.75rem; border: 1px solid var(--line); border-radius: 9px; background: rgba(255, 255, 255, 0.018); } +.diagnostic-row header { display: flex; align-items: start; justify-content: space-between; gap: 0.75rem; } +.diagnostic-row header > div { display: grid; gap: 0.2rem; min-width: 0; } +.diagnostic-row strong { color: #dedbe5; font-size: 0.75rem; } +.diagnostic-row small { color: var(--faint); font-family: var(--font-mono); font-size: 0.57rem; overflow-wrap: anywhere; } +.diagnostic-row p { margin-top: 0.55rem; color: var(--muted); font-size: 0.7rem; line-height: 1.55; } +.diagnostic-row code { display: block; margin-top: 0.55rem; color: #cbc7d3; font-family: var(--font-mono); font-size: 0.64rem; overflow-wrap: anywhere; } +.diagnostic-row > small { display: block; margin-top: 0.55rem; line-height: 1.5; } +.diagnostic-row ul { display: grid; gap: 0.35rem; padding: 0; margin: 0.65rem 0 0; list-style: none; } +.diagnostic-row li { padding: 0.35rem 0.45rem; border-radius: 5px; color: #cbc7d3; background: rgba(5, 5, 8, 0.5); font-family: var(--font-mono); font-size: 0.62rem; overflow-wrap: anywhere; } +.diagnostic-meta { border-top: 1px solid var(--line); } +.result-workflows { display: flex; flex-wrap: wrap; gap: 0.45rem; padding-top: 0.2rem; } +.result-workflows a { min-height: 34px; padding: 0 0.65rem; display: inline-flex; align-items: center; gap: 0.55rem; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); font-family: var(--font-mono); font-size: 0.59rem; } +.result-workflows a:hover { border-color: rgba(155, 135, 245, 0.45); color: var(--purple-bright); } /* Existing legal pages */ .simple-main { padding: 5rem 0 8rem; } @@ -425,6 +485,15 @@ main, .site-header, main, .site-footer { width: min(100% - 28px, 1160px); } .site-header { grid-template-columns: 1fr auto; min-height: 66px; } .nav-links { display: none; } + .launcher-trigger { min-width: 44px; min-height: 44px; padding: 0; justify-content: center; } + .launcher-label, .launcher-trigger kbd { display: none; } + .launcher-icon { font-size: 1.2rem; } + .launcher-backdrop { padding: 4.25rem 0.7rem 0.7rem; align-items: start; } + .tool-launcher { max-height: calc(100dvh - 5rem); } + .launcher-search-row { min-height: 58px; } + .launcher-search-row input { font-size: 16px; } + .launcher-results { max-height: calc(100dvh - 11rem); } + .tool-launcher footer { display: none; } .home-hero { min-height: auto; gap: 2rem; padding: 3.25rem 0 2.75rem; } .eyebrow { margin-bottom: 1.1rem; font-size: 0.62rem; line-height: 1.45; } .home-hero h1 { font-size: clamp(3.25rem, 16vw, 5rem); } @@ -434,6 +503,12 @@ main, .hero-actions .primary-button, .hero-actions .secondary-button { flex: 1 1 145px; } .hero-trust { gap: 0.65rem 1rem; margin-top: 1rem; font-size: 0.57rem; } + .quick-access { grid-template-columns: 1fr auto; gap: 0.6rem; margin-top: 0.75rem; padding: 0.75rem; } + .quick-access > span { grid-column: 1 / -1; } + .quick-access > div { min-width: 0; } + .quick-access > button { min-height: 44px; font-size: 0; } + .quick-access > button::before { content: "Find"; font-size: 0.62rem; } + .quick-access > button span { font-size: 0.56rem; } .terminal-bar { min-height: 38px; padding-inline: 0.75rem; } .terminal-foot { display: none; } .hero-terminal-body { min-height: 178px; max-height: 190px; padding: 0.85rem; overflow-x: auto; white-space: nowrap; line-height: 1.85; } @@ -488,7 +563,7 @@ main, .tool-card:hover { transform: none; } .tool-card-topline { grid-column: 1 / -1; align-items: center; } .command-icon { height: 30px; min-width: 34px; padding-inline: 0.45rem; border-radius: 6px; font-size: 0.52rem; } - .availability { align-self: center; font-size: 0.45rem; } + .availability { align-self: center; font-size: 0.52rem; } .tool-card h3 { margin-top: 0; font-size: 0.92rem; } .tool-card p { display: -webkit-box; @@ -523,7 +598,12 @@ main, .tool-page-header h1 { font-size: clamp(2.6rem, 13vw, 4rem); } .tool-command { display: none; } .tool-controls { padding: 1rem; } + .tool-controls input, .tool-controls textarea, .tool-controls select { font-size: 16px; } .result-list div { grid-template-columns: 1fr; gap: 0.35rem; } + .related-tools { grid-template-columns: 1fr; gap: 0.65rem; padding: 0.75rem; } + .related-tools > div { grid-template-columns: 1fr; } + .related-tools a { min-height: 44px; } + .diagnostic-row { padding: 0.7rem; } } @media (prefers-reduced-motion: reduce) { diff --git a/app/page.tsx b/app/page.tsx index 2339a62..e66a753 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -3,6 +3,7 @@ import type { Metadata } from "next"; import { HeroTerminal } from "@/components/landing/HeroTerminal"; import { SiteChrome } from "@/components/landing/SiteChrome"; import { ToolCard } from "@/components/tools/ToolCard"; +import { QuickAccess } from "@/components/tools/ToolDiscovery"; import { categoryMeta, getToolsByCategory, type ToolCategory } from "@/data/tools"; const categories: ToolCategory[] = ["networking", "ip", "developer"]; @@ -31,6 +32,8 @@ export default function HomePage() { + +
The toolbox

Everything you need.
Nothing you don't.

From quick network checks to everyday data transforms. Browser-ready tools stay local; live diagnostics run through a restricted same-origin API.

diff --git a/app/tools/[slug]/page.tsx b/app/tools/[slug]/page.tsx index 877b10b..c724b89 100644 --- a/app/tools/[slug]/page.tsx +++ b/app/tools/[slug]/page.tsx @@ -4,8 +4,9 @@ import { SiteChrome } from "@/components/landing/SiteChrome"; import { ToolExperience } from "@/components/tools/ToolExperience"; import { ToolLayout } from "@/components/tools/ToolLayout"; import { getTool, tools } from "@/data/tools"; +import { safeTargetPrefill } from "@/lib/tool-discovery"; -type ToolPageProps = { params: Promise<{ slug: string }> }; +type ToolPageProps = { params: Promise<{ slug: string }>; searchParams: Promise<{ target?: string | string[] }> }; export function generateStaticParams() { return tools.map((tool) => ({ slug: tool.slug })); @@ -27,13 +28,14 @@ export async function generateMetadata({ params }: ToolPageProps): Promise - + ); } diff --git a/components/landing/SiteChrome.tsx b/components/landing/SiteChrome.tsx index b31cf0c..2ed352b 100644 --- a/components/landing/SiteChrome.tsx +++ b/components/landing/SiteChrome.tsx @@ -1,4 +1,5 @@ import Link from "next/link"; +import { ToolLauncher } from "@/components/tools/ToolDiscovery"; type SiteChromeProps = { children: React.ReactNode; @@ -21,6 +22,7 @@ export function SiteHeader({ navHomePrefix = "" }: OmitDeveloper {docsUrl && Docs} + ); } diff --git a/components/tools/DiagnosticTools.tsx b/components/tools/DiagnosticTools.tsx new file mode 100644 index 0000000..e9d33aa --- /dev/null +++ b/components/tools/DiagnosticTools.tsx @@ -0,0 +1,218 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import type { Tool } from "@/data/tools"; +import type { + DiagnosticStatus, + DnsRecordType, + EmailDnsHealthResult, + HttpSecurityResult, + ResolverComparisonResult, + ResolverStatus +} from "@/app/api/_lib/diagnostic-types"; +import { DNS_RECORD_TYPES } from "@/app/api/_lib/diagnostic-types"; +import { ResultPanel } from "./ResultPanel"; + +type Envelope = { data?: unknown; error?: string; durationMs?: number; requestId?: string }; +type RunState = + | { kind: "idle" } + | { kind: "pending" } + | { kind: "error"; message: string; requestId?: string } + | { kind: "success"; data: unknown; durationMs?: number }; + +type BufferDashWindow = Window & { + bufferdash?: { track: (type: string, metadata?: Record) => void }; +}; + +function track(type: string, metadata?: Record) { + (window as BufferDashWindow).bufferdash?.track(type, metadata); +} + +export function DiagnosticToolExperience({ tool, initialTarget = "" }: { tool: Tool; initialTarget?: string }) { + if (tool.slug === "dns-resolver-check") return ; + if (tool.slug === "email-dns-health") return ; + return ; +} + +function ResolverComparison({ tool, initialTarget }: { tool: Tool; initialTarget: string }) { + const [domain, setDomain] = useState(initialTarget); + const [recordType, setRecordType] = useState("A"); + const [state, run] = useDiagnosticRequest(tool.slug); + + return ( + <> +
{ event.preventDefault(); run(domain, { recordType }); }}> + Compares public recursive resolvers; it is not a geographic propagation map. + + + Compare resolvers +

Queries Cloudflare, Google, Quad9, and OpenDNS concurrently. TTL differences are ignored.

+
+ {state.kind === "success" && } + + ); +} + +function EmailDnsHealth({ tool, initialTarget }: { tool: Tool; initialTarget: string }) { + const [domain, setDomain] = useState(initialTarget); + const [selector, setSelector] = useState(""); + const [state, run] = useDiagnosticRequest(tool.slug); + + return ( + <> +
{ event.preventDefault(); run(domain, selector.trim() ? { dkimSelector: selector } : undefined); }}> + Checks published DNS configuration—not inbox placement, reputation, or complete deliverability. + + + Check email DNS +

MX, SPF, DMARC, DKIM, MTA-STS, and TLS reporting are queried concurrently.

+
+ {state.kind === "success" && } + + ); +} + +function SecurityHeaders({ tool, initialTarget }: { tool: Tool; initialTarget: string }) { + const [url, setUrl] = useState(initialTarget); + const [state, run] = useDiagnosticRequest(tool.slug); + + return ( + <> +
{ event.preventDefault(); run(url); }}> + Inspects the final response after at most five safely validated redirects. No letter grade is assigned. + + Inspect security headers +

Uses HEAD when supported and does not download the page body.

+
+ {state.kind === "success" && } + + ); +} + +function useDiagnosticRequest(slug: string): [RunState, (input: string, options?: Record) => Promise] { + const [state, setState] = useState({ kind: "idle" }); + + async function run(input: string, options?: Record) { + setState({ kind: "pending" }); + try { + const response = await fetch(`/api/tools/${slug}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input, ...(options ? { options } : {}) }) + }); + const payload = await response.json() as Envelope; + if (!response.ok || payload.error) { + setState({ kind: "error", message: payload.error || `Request failed with HTTP ${response.status}.`, requestId: payload.requestId }); + track("tool_used", { tool: slug, outcome: "error", ...(typeof payload.durationMs === "number" ? { durationMs: Math.round(payload.durationMs) } : {}) }); + return; + } + setState({ kind: "success", data: payload.data, durationMs: payload.durationMs }); + track("tool_used", { tool: slug, outcome: "success", ...(typeof payload.durationMs === "number" ? { durationMs: Math.round(payload.durationMs) } : {}) }); + } catch (error) { + setState({ kind: "error", message: error instanceof Error ? error.message : "The diagnostics request failed." }); + track("tool_used", { tool: slug, outcome: "error" }); + } + } + + return [state, run]; +} + +function DiagnosticPanel({ slug, state, children }: { slug: string; state: RunState; children: React.ReactNode }) { + const status = state.kind === "success" ? "success" : state.kind === "error" ? "error" : state.kind === "pending" ? "pending" : "idle"; + return ( + + {state.kind === "idle" &&

$ waiting for a target

} + {state.kind === "pending" &&
} + {state.kind === "error" &&
ERROR

Request did not complete

{state.message}

{state.requestId && requestId: {state.requestId}}
} + {state.kind === "success" &&
{children}
} +
+ ); +} + +function ResolverResults({ result }: { result: ResolverComparisonResult }) { + return ( + <> +
{result.summary.uniqueAnswerSets} unique answer set{result.summary.uniqueAnswerSets === 1 ? "" : "s"} · {result.summary.successfulResolvers}/{result.summary.totalResolvers} answered
+
{result.resolvers.map((resolver) => ( +
+
{resolver.name}{resolver.address} · {resolver.latencyMs}ms
+ {resolver.answers.length ?
    {resolver.answers.map((answer) =>
  • {answer}
  • )}
:

{resolver.error || "No answer was returned."}

} +
+ ))}
+ + + ); +} + +function EmailResults({ result }: { result: EmailDnsHealthResult }) { + return ( + <> + +
{result.checks.map((check) => ( +
+
{check.label}
+

{check.summary}

+ {check.records?.length ?
    {check.records.map((record) =>
  • {record}
  • )}
: null} +
+ ))}
+

This report checks published DNS configuration only. It does not measure inbox placement, sender reputation, or complete deliverability.

+ + + ); +} + +function SecurityResults({ result }: { result: HttpSecurityResult }) { + return ( + <> +
Requested URL
{result.requestedUrl}
Final URL
{result.finalUrl}
Response
HTTP {result.statusCode} · {result.redirectCount} redirect{result.redirectCount === 1 ? "" : "s"}
+ +
{result.checks.map((check) => ( +
+
{check.label}
+

{check.summary}

+ {check.observedValue && {check.observedValue}} + {check.recommendation && {check.recommendation}} +
+ ))}
+ + + ); +} + +function SummaryCounts({ summary }: { summary: Record }) { + return
{(["pass", "warning", "fail", "info", "error"] as const).filter((status) => summary[status] > 0).map((status) => {summary[status]})}
; +} + +export function StatusChip({ status, label }: { status: DiagnosticStatus | ResolverStatus; label?: string }) { + return {label || status.replace("-", " ")}; +} + +function WorkflowLinks({ source, target, links }: { source: string; target: string; links: string[] }) { + const names: Record = { "dns-lookup": "DNS Lookup", "email-dns-health": "Email DNS Health", "dns-resolver-check": "DNS Resolver Comparison", "ssl-checker": "SSL Certificate Checker", "http-headers": "HTTP Header Inspector", "redirect-checker": "Redirect Checker" }; + return
{links.map((slug) => track("related_tool_opened", { source, destination: slug })}>{names[slug]} )}
; +} + +function CopyJsonButton({ value }: { value: unknown }) { + const [copied, setCopied] = useState(false); + async function copy() { + try { + await navigator.clipboard.writeText(JSON.stringify(value, null, 2)); + setCopied(true); + window.setTimeout(() => setCopied(false), 3_000); + } catch { setCopied(false); } + } + return ; +} + +function DiagnosticBanner({ children }: { children: React.ReactNode }) { + return
Server diagnostic

{children}

; +} + +function FieldLabel({ children }: { children: React.ReactNode }) { + return {children}; +} + +function RunButton({ pending, children }: { pending: boolean; children: React.ReactNode }) { + return
; +} diff --git a/components/tools/ToolDiscovery.tsx b/components/tools/ToolDiscovery.tsx new file mode 100644 index 0000000..98bfaef --- /dev/null +++ b/components/tools/ToolDiscovery.tsx @@ -0,0 +1,193 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { DEFAULT_QUICK_ACCESS, readRecentTools, recordRecentTool, searchTools } from "@/lib/tool-discovery"; +import { getTool, tools, type Tool } from "@/data/tools"; + +type LauncherLocation = "header" | "quick_access"; +type BufferDashWindow = Window & { + bufferdash?: { track: (type: string, metadata?: Record) => void }; +}; + +const VALID_SLUGS = new Set(tools.map((tool) => tool.slug)); +const RECENTS_CHANGED_EVENT = "buffer:recent-tools-changed"; +const OPEN_LAUNCHER_EVENT = "buffer:open-tool-launcher"; + +function track(type: string, metadata?: Record) { + (window as BufferDashWindow).bufferdash?.track(type, metadata); +} + +function getQuickSlugs() { + if (typeof window === "undefined") return DEFAULT_QUICK_ACCESS; + try { + const recent = readRecentTools(window.localStorage, VALID_SLUGS); + return recent.length ? recent : DEFAULT_QUICK_ACCESS; + } catch { + return DEFAULT_QUICK_ACCESS; + } +} + +export function ToolLauncher() { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const [quickSlugs, setQuickSlugs] = useState(DEFAULT_QUICK_ACCESS); + const triggerRef = useRef(null); + const inputRef = useRef(null); + + const results = useMemo(() => query.trim() + ? searchTools(tools, query) + : quickSlugs.map(getTool).filter((tool): tool is Tool => Boolean(tool)), [query, quickSlugs]); + + function show(location: LauncherLocation) { + setQuickSlugs(getQuickSlugs()); + setOpen(true); + setQuery(""); + setActiveIndex(0); + track("tool_launcher_opened", { location }); + } + + function close() { + setOpen(false); + window.setTimeout(() => triggerRef.current?.focus(), 0); + } + + function choose(tool: Tool, source: string) { + track("tool_selected", { tool: tool.slug, source }); + setOpen(false); + } + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + open ? close() : show("header"); + } else if (open && event.key === "Escape") { + event.preventDefault(); + close(); + } + } + + function onOpen(event: Event) { + const location = (event as CustomEvent).detail; + show(location === "quick_access" ? "quick_access" : "header"); + } + + window.addEventListener("keydown", onKeyDown); + window.addEventListener(OPEN_LAUNCHER_EVENT, onOpen); + return () => { + window.removeEventListener("keydown", onKeyDown); + window.removeEventListener(OPEN_LAUNCHER_EVENT, onOpen); + }; + }, [open]); + + useEffect(() => { + if (open) window.setTimeout(() => inputRef.current?.focus(), 0); + }, [open]); + + return ( + <> + + {open && ( +
{ if (event.target === event.currentTarget) close(); }}> +
+
+ + + { setQuery(event.target.value); setActiveIndex(0); }} + onKeyDown={(event) => { + if (event.key === "ArrowDown") { event.preventDefault(); setActiveIndex((index) => Math.min(results.length - 1, index + 1)); } + if (event.key === "ArrowUp") { event.preventDefault(); setActiveIndex((index) => Math.max(0, index - 1)); } + if (event.key === "Enter" && results[activeIndex]) { + event.preventDefault(); + choose(results[activeIndex], query.trim() ? "launcher_search" : "launcher_quick_access"); + window.location.assign(`/tools/${results[activeIndex].slug}`); + } + }} + placeholder="Search names, commands, or keywords…" + autoComplete="off" + /> + +
+
+

{query.trim() ? `${results.length} match${results.length === 1 ? "" : "es"}` : "Quick access"}

+ {results.map((tool, index) => ( + setActiveIndex(index)} + onClick={() => choose(tool, query.trim() ? "launcher_search" : "launcher_quick_access")} + > + + {tool.name}{tool.description} + + + ))} + {!results.length &&
No tools match every search term.
} +
+
↑↓ Navigate↵ OpenEsc Close
+
+
+ )} + + ); +} + +export function QuickAccess() { + const [slugs, setSlugs] = useState(DEFAULT_QUICK_ACCESS); + + useEffect(() => { + const refresh = () => setSlugs(getQuickSlugs()); + refresh(); + window.addEventListener(RECENTS_CHANGED_EVENT, refresh); + return () => window.removeEventListener(RECENTS_CHANGED_EVENT, refresh); + }, []); + + const quickTools = slugs.map(getTool).filter((tool): tool is Tool => Boolean(tool)); + + return ( +
+ Quick access +
{quickTools.map((tool) => ( + track("tool_selected", { tool: tool.slug, source: "quick_access" })}>{tool.name} + ))}
+ +
+ ); +} + +export function ToolVisitTracker({ slug }: { slug: string }) { + useEffect(() => { + try { + recordRecentTool(window.localStorage, slug, VALID_SLUGS); + window.dispatchEvent(new Event(RECENTS_CHANGED_EVENT)); + } catch { /* Recent tools are intentionally best-effort. */ } + }, [slug]); + return null; +} + +export function RelatedTools({ source, related, target }: { source: string; related: Tool[]; target?: string }) { + return ( + + ); +} diff --git a/components/tools/ToolExperience.tsx b/components/tools/ToolExperience.tsx index f5b9046..0df8687 100644 --- a/components/tools/ToolExperience.tsx +++ b/components/tools/ToolExperience.tsx @@ -3,6 +3,7 @@ import { useMemo, useState, useSyncExternalStore } from "react"; import type { Tool } from "@/data/tools"; import { ResultPanel } from "./ResultPanel"; +import { DiagnosticToolExperience } from "./DiagnosticTools"; type BufferDashWindow = Window & { bufferdash?: { @@ -16,7 +17,10 @@ function trackToolUse(tool: string, outcome: "success" | "error", durationMs?: n (window as BufferDashWindow).bufferdash?.track("tool_used", metadata); } -export function ToolExperience({ tool }: { tool: Tool }) { +export function ToolExperience({ tool, initialTarget = "" }: { tool: Tool; initialTarget?: string }) { + if (["dns-resolver-check", "email-dns-health", "security-headers"].includes(tool.slug)) { + return ; + } switch (tool.slug) { case "ping": return ; case "packet-loss": return ; @@ -30,7 +34,7 @@ export function ToolExperience({ tool }: { tool: Tool }) { case "jwt-decoder": return ; case "regex-tester": return ; case "cidr-calculator": return ; - default: return ; + default: return ; } } @@ -693,8 +697,8 @@ function wait(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } -function BackendPlaceholder({ tool }: { tool: Tool }) { - const [input, setInput] = useState(""); +function BackendPlaceholder({ tool, initialTarget = "" }: { tool: Tool; initialTarget?: string }) { + const [input, setInput] = useState(initialTarget); const [result, setResult] = useState({ kind: "idle", message: "Waiting for input." }); const status = result.kind === "success" ? "success" : result.kind === "error" ? "error" : result.kind === "pending" ? "pending" : "idle"; const isLiveBackendTool = tool.status === "available"; diff --git a/components/tools/ToolLayout.tsx b/components/tools/ToolLayout.tsx index 008a3bc..ea26ef9 100644 --- a/components/tools/ToolLayout.tsx +++ b/components/tools/ToolLayout.tsx @@ -1,9 +1,13 @@ import Link from "next/link"; import type { ReactNode } from "react"; import { categoryMeta, type Tool } from "@/data/tools"; +import { getTool, getToolsByCategory } from "@/data/tools"; +import { RelatedTools, ToolVisitTracker } from "./ToolDiscovery"; -export function ToolLayout({ tool, children }: { tool: Tool; children: ReactNode }) { +export function ToolLayout({ tool, children, target }: { tool: Tool; children: ReactNode; target?: string }) { const category = categoryMeta[tool.category]; + const related = (tool.relatedSlugs?.map(getTool).filter((item): item is Tool => Boolean(item)) + ?? getToolsByCategory(tool.category).filter((item) => item.slug !== tool.slug)).slice(0, 3); return (
@@ -22,7 +26,9 @@ export function ToolLayout({ tool, children }: { tool: Tool; children: ReactNode
$ {tool.command}