diff --git a/__tests__/dmarc-dns.test.ts b/__tests__/dmarc-dns.test.ts new file mode 100644 index 0000000..cc09700 --- /dev/null +++ b/__tests__/dmarc-dns.test.ts @@ -0,0 +1,33 @@ +// __tests__/dmarc-dns.test.ts +import { describe, it, expect } from "vitest"; +import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns"; + +describe("classifyDnsError", () => { + it("treats NXDOMAIN / no-data as a genuinely missing record", () => { + expect(classifyDnsError({ code: "ENOTFOUND" })).toBe("missing"); + expect(classifyDnsError({ code: "ENODATA" })).toBe("missing"); + }); + + it("treats resolver failures as transient, never a verdict", () => { + expect(classifyDnsError({ code: "ESERVFAIL" })).toBe("transient"); + expect(classifyDnsError({ code: "ETIMEOUT" })).toBe("transient"); + expect(classifyDnsError({ code: "ECONNREFUSED" })).toBe("transient"); + expect(classifyDnsError(new Error("boom"))).toBe("transient"); + expect(classifyDnsError(null)).toBe("transient"); + }); +}); + +describe("isDkimKeyRecord", () => { + it("accepts real DKIM key records", () => { + expect(isDkimKeyRecord("v=DKIM1; k=rsa; p=MIGfMA0GCSq")).toBe(true); + expect(isDkimKeyRecord("k=rsa; p=MIGfMA0GCSq")).toBe(true); // v= tag omitted + expect(isDkimKeyRecord("v=DKIM1; p=")).toBe(true); // revoked, still a DKIM record + }); + + it("rejects unrelated TXT records at the probed name", () => { + expect(isDkimKeyRecord("v=spf1 include:_spf.google.com -all")).toBe(false); + expect(isDkimKeyRecord("google-site-verification=abc123")).toBe(false); + expect(isDkimKeyRecord("MS=ms12345678")).toBe(false); + expect(isDkimKeyRecord("")).toBe(false); + }); +}); diff --git a/app/api/tools/dmarc-check/route.ts b/app/api/tools/dmarc-check/route.ts index 6f3b7b3..e506135 100644 --- a/app/api/tools/dmarc-check/route.ts +++ b/app/api/tools/dmarc-check/route.ts @@ -6,6 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import { promises as dns } from "node:dns"; import { rateLimit } from "@/server/rate-limit"; +import { classifyDnsError, isDkimKeyRecord } from "@/lib/dmarc-dns"; // Common DKIM selectors used by major mail providers. We probe these because // DKIM selector names aren't discoverable from the domain itself — DNS gives @@ -131,23 +132,40 @@ function isValidDomain(s: string): boolean { return re.test(s); } +// Resolve a TXT lookup, distinguishing "no such record" (return null) from a +// transient resolver failure (throw) so a SERVFAIL/timeout never masquerades as +// a missing record and falsely tanks the score. async function lookupTxt(host: string): Promise { try { return await dns.resolveTxt(host); - } catch { + } catch (err) { + if (classifyDnsError(err) === "transient") throw err; return null; } } async function lookupMx(domain: string): Promise<{ exchange: string; priority: number }[] | null> { try { - const records = await dns.resolveMx(domain); - return records.sort((a, b) => a.priority - b.priority); - } catch { + return (await dns.resolveMx(domain)).sort((a, b) => a.priority - b.priority); + } catch (err) { + if (classifyDnsError(err) === "transient") throw err; return null; } } +// DKIM selector probe — best-effort. A non-existent selector (the common case) +// or any resolver hiccup is simply "not found"; a present TXT counts only when +// it's actually a DKIM key, not a stray record sitting at the probed name. +async function probeDkim(selector: string, domain: string): Promise { + try { + const txt = await dns.resolveTxt(`${selector}._domainkey.${domain}`); + const record = txt.map((parts) => parts.join("")).find(isDkimKeyRecord); + return { selector, found: !!record, record }; + } catch { + return { selector, found: false }; + } +} + function findSpf(txtRecords: string[][] | null): string | null { if (!txtRecords) return null; // SPF records may be split across multiple strings in a single TXT record. @@ -320,19 +338,28 @@ export async function POST(request: NextRequest) { ); } - // Run all DNS lookups in parallel for speed. - const [rootTxt, dmarcTxt, mxRecords, ...dkimResults] = await Promise.all([ - lookupTxt(domain), - lookupTxt(`_dmarc.${domain}`), - lookupMx(domain), - ...DKIM_SELECTORS.map((selector) => - lookupTxt(`${selector}._domainkey.${domain}`).then((txt) => ({ - selector, - found: !!txt && txt.length > 0, - record: txt?.[0]?.join("") ?? undefined, - })) - ), - ]); + // Core records first: a transient resolver failure here must surface as an + // error, not as a false "everything is missing" verdict. + let rootTxt: string[][] | null; + let dmarcTxt: string[][] | null; + let mxRecords: { exchange: string; priority: number }[] | null; + try { + [rootTxt, dmarcTxt, mxRecords] = await Promise.all([ + lookupTxt(domain), + lookupTxt(`_dmarc.${domain}`), + lookupMx(domain), + ]); + } catch { + return NextResponse.json( + { error: "Couldn't complete the DNS lookup right now. Please try again in a moment." }, + { status: 503 } + ); + } + + // DKIM selector probes are best-effort and never fail the whole request. + const dkimResults = await Promise.all( + DKIM_SELECTORS.map((selector) => probeDkim(selector, domain)) + ); const spfRecord = findSpf(rootTxt); const spf = { record: spfRecord, ...analyzeSpf(spfRecord) }; @@ -340,7 +367,7 @@ export async function POST(request: NextRequest) { const dmarcRecord = findDmarc(dmarcTxt); const dmarcAnalysis = analyzeDmarc(dmarcRecord); - const dkimFound = (dkimResults as DkimResult[]).filter((r) => r.found); + const dkimFound = dkimResults.filter((r) => r.found); const dkimStatus: "ok" | "warn" | "missing" = dkimFound.length >= 1 ? "ok" : "missing"; diff --git a/app/layout.tsx b/app/layout.tsx index c1a7c2c..62089f9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -40,8 +40,9 @@ export async function generateMetadata(): Promise { siteName: "Akritos", title: TITLE, description: DESCRIPTION, + images: ["/og-default.png"], }, - twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION }, + twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: ["/og-default.png"] }, robots: { index: true, follow: true }, alternates: { canonical: "https://akritos.com" }, verification: { diff --git a/app/services/page.tsx b/app/services/page.tsx index 36260d1..27da329 100644 --- a/app/services/page.tsx +++ b/app/services/page.tsx @@ -27,8 +27,17 @@ import { ShieldAlert, Key, Mail, + type LucideIcon, } from "lucide-react"; +type ServiceEntry = { + icon: LucideIcon; + title: string; + description: string; + details: string[]; + link?: { label: string; href: string }; +}; + async function getCompanyName() { try { const setting = await db.setting.findUnique({ where: { key: "company_name" } }); @@ -95,7 +104,7 @@ const coreServices = [ }, ]; -const advisoryServices = [ +const advisoryServices: ServiceEntry[] = [ { icon: CreditCard, title: "Payment Processing & PCI Scope Reduction", @@ -155,8 +164,8 @@ const advisoryServices = [ "Vendor evaluation — data terms, audit trail, exit risk", "Team training — spotting confidently-wrong output", "SME workflow design — keeping the human as decision-maker", - "Linked: detailed page at /ai-risk", ], + link: { label: "Read the full AI risk & guardrails breakdown", href: "/ai-risk" }, }, { icon: Mail, @@ -169,8 +178,8 @@ const advisoryServices = [ "30-day monitoring period to catch legitimate senders we missed", "Move to p=quarantine or p=reject once alignment is clean", "Documentation you own — DNS records, provider settings, monitoring access", - "Linked: free checker tool at /tools/dmarc-check", ], + link: { label: "Check your domain with the free DMARC, SPF & DKIM checker", href: "/tools/dmarc-check" }, }, ]; @@ -364,6 +373,14 @@ export default async function ServicesPage() {

{s.description}

+ {s.link && ( + + {s.link.label} + + )}
    {s.details.map((d) => ( diff --git a/app/tools/dmarc-check/page.tsx b/app/tools/dmarc-check/page.tsx index 8bfb6cc..5a698ec 100644 --- a/app/tools/dmarc-check/page.tsx +++ b/app/tools/dmarc-check/page.tsx @@ -6,12 +6,14 @@ import Link from "next/link"; import { SiteNav } from "@/components/site/site-nav"; import { SiteFooter } from "@/components/site/site-footer"; import { DmarcCheckForm } from "@/components/tools/dmarc-check-form"; +import { JsonLd, softwareApplicationSchema, breadcrumbSchema } from "@/components/site/json-ld"; import { db } from "@/server/db"; import { ArrowRight, Mail, ShieldCheck, MailWarning } from "lucide-react"; const SITE_URL = "https://akritos.com"; const URL = `${SITE_URL}/tools/dmarc-check`; -const TITLE = "Free DMARC, SPF & DKIM Checker — Akritos"; +// No brand suffix here — the root layout's title template appends " | Akritos". +const TITLE = "Free DMARC, SPF & DKIM Checker"; const DESCRIPTION = "Free tool: enter your domain to instantly check SPF, DKIM, DMARC, and MX records. Plain-English explanations of what's wrong and how to fix it. No email required to use."; @@ -25,8 +27,9 @@ export const metadata: Metadata = { title: TITLE, description: DESCRIPTION, siteName: "Akritos", + images: [`${SITE_URL}/og-default.png`], }, - twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION }, + twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [`${SITE_URL}/og-default.png`] }, }; async function getCompanyName() { @@ -61,6 +64,13 @@ export default async function DmarcCheckPage() { return (
    + + {/* Hero + form */} diff --git a/app/tools/dmarc-report/page.tsx b/app/tools/dmarc-report/page.tsx index b59fc35..a808fdb 100644 --- a/app/tools/dmarc-report/page.tsx +++ b/app/tools/dmarc-report/page.tsx @@ -6,6 +6,7 @@ import Link from "next/link"; import { SiteNav } from "@/components/site/site-nav"; import { SiteFooter } from "@/components/site/site-footer"; import { DmarcReportForm } from "@/components/tools/dmarc-report-form"; +import { JsonLd, softwareApplicationSchema, breadcrumbSchema } from "@/components/site/json-ld"; import { db } from "@/server/db"; import { ArrowRight, FileSearch, ShieldCheck, Eye } from "lucide-react"; @@ -25,8 +26,9 @@ export const metadata: Metadata = { title: TITLE, description: DESCRIPTION, siteName: "Akritos", + images: [`${SITE_URL}/og-default.png`], }, - twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION }, + twitter: { card: "summary_large_image", title: TITLE, description: DESCRIPTION, images: [`${SITE_URL}/og-default.png`] }, }; async function getCompanyName() { @@ -61,6 +63,13 @@ export default async function DmarcReportPage() { return (
    + + {/* Hero + tool */} diff --git a/components/site/json-ld.tsx b/components/site/json-ld.tsx index 56636e5..52e9052 100644 --- a/components/site/json-ld.tsx +++ b/components/site/json-ld.tsx @@ -176,3 +176,29 @@ export function breadcrumbSchema(items: { name: string; url: string }[]) { })), }; } + +export function softwareApplicationSchema(app: { + name: string; + description: string; + url: string; + category?: string; +}) { + return { + "@context": "https://schema.org", + "@type": "WebApplication", + name: app.name, + description: app.description, + url: app.url, + applicationCategory: app.category ?? "SecurityApplication", + operatingSystem: "Web", + browserRequirements: "Requires JavaScript.", + // `offers` (free), not a fabricated aggregateRating, is what makes the tool + // eligible for the Software App rich result — honest, no invented reviews. + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, + provider: { + "@type": "Organization", + name: "Akritos Technology Partners LLC", + url: "https://akritos.com", + }, + }; +} diff --git a/docs/LESSONS.md b/docs/LESSONS.md index fda0820..e7a4659 100644 --- a/docs/LESSONS.md +++ b/docs/LESSONS.md @@ -226,6 +226,26 @@ Read at session start (loaded by `/boot`). Add to it whenever: --- +## 2026-06-18 — File-based `opengraph-image` doesn't cover pages that export their own `openGraph` + +**What happened:** Added a root `app/opengraph-image.tsx` as a site-wide default card. Curling `/tools/dmarc-check` showed no `og:image` — a page that exports its own `metadata.openGraph` object does not inherit an ancestor segment's `opengraph-image` file; the file convention only fills routes that don't define `openGraph` themselves. + +**Lesson:** For any page with custom `openGraph`/`twitter` metadata, set `images` explicitly. We dropped the dynamic route for one static `public/og-default.png` referenced from the root layout (default) and from each overriding page. Verify `og:image` with a real request — never assume inheritance. + +**Where it applies:** Every public page with custom OG/Twitter metadata. Brand rasters are regenerated via `scripts/gen-brand-assets.mjs`. + +--- + +## 2026-06-18 — DNS "no record" must be distinguished from a resolver failure + +**What happened:** The DMARC checker's `lookupTxt`/`lookupMx` swallowed every DNS error to `null`, so a transient SERVFAIL/timeout read identically to NXDOMAIN — telling a user on a flaky network they have no SPF/DMARC and tanking their score. Separately, any TXT at a probed `_domainkey` selector counted as a valid DKIM key (+20), even an unrelated record. + +**Lesson:** Classify DNS errors by code (`ENOTFOUND`/`ENODATA` = genuinely missing; anything else = transient → surface an error, don't assert "missing"). When probing for a record type, validate the content (a DKIM key declares `v=DKIM1` or `p=`) — presence at the name is not a match. + +**Where it applies:** `app/api/tools/dmarc-check/route.ts`, `lib/dmarc-dns.ts`. Any DNS-probe-based checker. + +--- + ## How to add to this file When you finish a task and a real lesson emerged, add an entry. Keep it terse. The point is to avoid repeating the mistake — not to write an essay. If the lesson is big enough to drive an architectural change, it goes in `docs/DECISIONS.md` instead. If it's about how the codebase works, update `CLAUDE.md`. If it's about how *we* work — it lives here. diff --git a/lib/dmarc-dns.ts b/lib/dmarc-dns.ts new file mode 100644 index 0000000..ee0f4dd --- /dev/null +++ b/lib/dmarc-dns.ts @@ -0,0 +1,28 @@ +// lib/dmarc-dns.ts +// Pure DNS-record classification helpers for the email-auth checker. Kept out of +// the route handler so they can be unit-tested without performing live DNS. + +/** + * Classify a node:dns rejection. A record that genuinely doesn't exist must not + * be confused with a resolver that's momentarily failing — otherwise a SERVFAIL + * or timeout gets reported to the user as "you have no SPF/DMARC", tanking their + * score on a false negative. + */ +export function classifyDnsError(err: unknown): "missing" | "transient" { + const code = (err as { code?: string } | null)?.code; + // ENOTFOUND = NXDOMAIN; ENODATA = name exists but no record of this type. + // Both mean the record truly isn't there. Everything else (SERVFAIL, timeout, + // refused, …) is a resolver problem, not a verdict about the domain. + if (code === "ENOTFOUND" || code === "ENODATA") return "missing"; + return "transient"; +} + +/** + * True only when a TXT record at `._domainkey` is actually a DKIM key + * — it declares v=DKIM1 or publishes a p= public-key tag. Guards against + * counting an unrelated/leftover TXT at the probed name as a valid selector, + * which would otherwise award DKIM credit for a record that isn't DKIM. + */ +export function isDkimKeyRecord(record: string): boolean { + return /(^|;|\s)v=DKIM1\b/i.test(record) || /(^|;|\s)p=/i.test(record); +} diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..00950a9 Binary files /dev/null and b/public/logo.png differ diff --git a/public/og-default.png b/public/og-default.png new file mode 100644 index 0000000..f1fd3e0 Binary files /dev/null and b/public/og-default.png differ diff --git a/scripts/gen-brand-assets.mjs b/scripts/gen-brand-assets.mjs new file mode 100644 index 0000000..4a739fb --- /dev/null +++ b/scripts/gen-brand-assets.mjs @@ -0,0 +1,70 @@ +// scripts/gen-brand-assets.mjs +// One-off generator for the static brand rasters: og-default.png (the Open +// Graph / Twitter card and the JSON-LD Article image fallback) and logo.png +// (the JSON-LD Organization logo). Re-run when the brand card changes: +// npx tsx scripts/gen-brand-assets.mjs +// Uses next/og (already a dependency) so there's no new tooling to maintain. + +import { createElement as h } from "react"; +import { ImageResponse } from "next/og"; +import { writeFile } from "node:fs/promises"; + +const MIDNIGHT = "#1C1F2E"; +const GOLD = "#C8A96E"; +const BONE = "#E8E4DC"; + +const ogCard = h( + "div", + { + style: { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + padding: 80, + background: MIDNIGHT, + color: BONE, + }, + }, + [ + h("div", { key: "top", style: { display: "flex", alignItems: "center", gap: 16 } }, [ + h("div", { key: "m", style: { width: 36, height: 36, background: GOLD, borderRadius: 2 } }), + h("div", { key: "w", style: { fontSize: 22, letterSpacing: "0.2em", textTransform: "uppercase", color: GOLD } }, "Akritos"), + ]), + h("div", { key: "mid", style: { display: "flex", flexDirection: "column", gap: 24 } }, [ + h("div", { key: "h", style: { fontSize: 60, fontWeight: 500, lineHeight: 1.1, maxWidth: 920 } }, "Technology Partners for Small Business"), + h("div", { key: "s", style: { fontSize: 28, color: "rgba(232, 228, 220, 0.6)", maxWidth: 920, lineHeight: 1.4 } }, "Apple Business, MDM, infrastructure, e-commerce — published rates, zero vendor markup, no lock-in."), + ]), + h("div", { key: "url", style: { fontSize: 20, color: "rgba(232, 228, 220, 0.4)" } }, "akritos.com"), + ] +); + +const logoMark = h( + "div", + { + style: { + width: "100%", + height: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 28, + background: MIDNIGHT, + }, + }, + [ + h("div", { key: "m", style: { width: 96, height: 96, background: GOLD, borderRadius: 6 } }), + h("div", { key: "w", style: { fontSize: 64, fontWeight: 600, letterSpacing: "0.12em", color: BONE } }, "AKRITOS"), + ] +); + +async function toPng(node, size) { + const res = new ImageResponse(node, size); + return Buffer.from(await res.arrayBuffer()); +} + +await writeFile("public/og-default.png", await toPng(ogCard, { width: 1200, height: 630 })); +await writeFile("public/logo.png", await toPng(logoMark, { width: 512, height: 512 })); +console.log("wrote public/og-default.png and public/logo.png");