From 5b609329412925e54e82f6073572afe220a3c282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:34:30 +0530 Subject: [PATCH 01/24] feat(domains): add snapshot run repository --- src/repositories/domainCheckRun.repository.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/repositories/domainCheckRun.repository.ts diff --git a/src/repositories/domainCheckRun.repository.ts b/src/repositories/domainCheckRun.repository.ts new file mode 100644 index 0000000..0fec94a --- /dev/null +++ b/src/repositories/domainCheckRun.repository.ts @@ -0,0 +1,26 @@ +import { and, desc, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { domainCheckRuns, domains } from "../db/schema"; +import type { DomainEnrichment } from "../domains/enrichment/types"; + +export type DomainCheckRun = typeof domainCheckRuns.$inferSelect; + +export async function insertDomainCheckRun(input: { + id: string; + domainId: string; + checkedAt: Date; + result: DomainEnrichment; + error?: string | null; +}): Promise { + const [row] = await db.insert(domainCheckRuns).values(input).returning(); + return row; +} + +export async function findDomainCheckRunsForUser(domainId: string, userId: string, limit = 20) { + const [owned] = await db.select({ id: domains.id }).from(domains) + .where(and(eq(domains.id, domainId), eq(domains.userId, userId))).limit(1); + if (!owned) return null; + return db.select().from(domainCheckRuns) + .where(eq(domainCheckRuns.domainId, domainId)) + .orderBy(desc(domainCheckRuns.checkedAt)).limit(limit); +} From c46c6786456d16a01f2be5e11cb4087280e32721 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:34:37 +0530 Subject: [PATCH 02/24] feat(domains): model append-only check snapshots --- src/db/schema.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/db/schema.ts b/src/db/schema.ts index 878b1a8..221aeea 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -169,6 +169,15 @@ export const infrastructureServers = pgTable("infrastructure_servers", { .defaultNow(), }); +/** Append-only results for explicit domain checks; deleting a domain deletes its history. */ +export const domainCheckRuns = pgTable("domain_check_runs", { + id: text("id").primaryKey(), + domainId: text("domainId").notNull().references(() => domains.id, { onDelete: "cascade" }), + checkedAt: timestamp("checkedAt", { mode: "date", precision: 3 }).notNull().defaultNow(), + result: jsonb("result").$type().notNull(), + error: text("error"), +}); + export const authSchema = { user, session, From ff35b691ae660c9be2a54e50d9123460e761a7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:34:53 +0530 Subject: [PATCH 03/24] feat(domains): src/domains/enrichment/ratelimit.ts --- src/domains/enrichment/ratelimit.ts | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/domains/enrichment/ratelimit.ts b/src/domains/enrichment/ratelimit.ts index 3b5de62..cfe2511 100644 --- a/src/domains/enrichment/ratelimit.ts +++ b/src/domains/enrichment/ratelimit.ts @@ -1,18 +1,32 @@ const WINDOW_MS = 60_000; const MAX_REQUESTS = 24; +const DOMAIN_WINDOW_MS = 60_000; +const MAX_DOMAIN_REQUESTS = 1; const hits = new Map(); +const domainHits = new Map(); -export function rateLimitDomainLookup(userId: string): { ok: true } | { ok: false; retryAfterSec: number } { +function consume(map: Map, key: string, max: number, windowMs: number): + { ok: true } | { ok: false; retryAfterSec: number } { const now = Date.now(); - const prev = hits.get(userId) ?? []; - const fresh = prev.filter((t) => now - t < WINDOW_MS); - if (fresh.length >= MAX_REQUESTS) { - const oldest = Math.min(...fresh); - const retryAfterSec = Math.ceil((WINDOW_MS - (now - oldest)) / 1000); + const fresh = (map.get(key) ?? []).filter((t) => now - t < windowMs); + if (fresh.length >= max) { + const retryAfterSec = Math.ceil((windowMs - (now - Math.min(...fresh))) / 1000); + map.set(key, fresh); return { ok: false, retryAfterSec: Math.max(1, retryAfterSec) }; } fresh.push(now); - hits.set(userId, fresh); + map.set(key, fresh); return { ok: true }; } + +export function rateLimitDomainLookup(userId: string) { + return consume(hits, userId, MAX_REQUESTS, WINDOW_MS); +} + +/** Keep explicit refreshes to one per domain per minute, in addition to the user-wide limit. */ +export function rateLimitDomainCheck(userId: string, domainId: string) { + const user = consume(hits, userId, MAX_REQUESTS, WINDOW_MS); + if (!user.ok) return user; + return consume(domainHits, `${userId}:${domainId}`, MAX_DOMAIN_REQUESTS, DOMAIN_WINDOW_MS); +} From c0947074ffea131057199f56251f85d40a8aae74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:34:57 +0530 Subject: [PATCH 04/24] feat(domains): src/services/domainCheck.service.ts --- src/services/domainCheck.service.ts | 86 +++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/services/domainCheck.service.ts diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts new file mode 100644 index 0000000..7c4c780 --- /dev/null +++ b/src/services/domainCheck.service.ts @@ -0,0 +1,86 @@ +import type { ApiResult } from "../types/api"; +import { enrichDomain } from "../domains/enrichment/service"; +import { enrichmentToStorable } from "../models/domain.model"; +import { findDomainByIdForUser, updateDomainForUser } from "../repositories/domain.repository"; +import { findDomainCheckRunsForUser, insertDomainCheckRun } from "../repositories/domainCheckRun.repository"; +import { rateLimitDomainCheck } from "../domains/enrichment/ratelimit"; + +const HISTORY_LIMIT = 20; + +function unauthorized(): ApiResult { return { status: 401, body: { error: "Unauthorized" } }; } +function notFound(): ApiResult { return { status: 404, body: { error: "Not found" } }; } + +export async function refreshDomainCheckForUser(userId: string | null, domainId: string): Promise { + if (!userId) return unauthorized(); + if (!domainId) return notFound(); + + let domain: Awaited>; + try { + domain = await findDomainByIdForUser(domainId, userId); + } catch (error) { + return { status: 500, body: { error: error instanceof Error ? error.message : "Database error" } }; + } + if (!domain) return notFound(); + + const limited = rateLimitDomainCheck(userId, domainId); + if (!limited.ok) { + return { status: 429, body: { + error: "This domain was checked recently. Try again shortly.", + retryAfter: limited.retryAfterSec, + } }; + } + + const checkedAt = new Date(); + let result; + let errorMessage: string | null = null; + try { + result = enrichmentToStorable(await enrichDomain(domain.hostname)); + } catch (error) { + errorMessage = error instanceof Error ? error.message : "Domain check failed"; + result = { fetchedAt: checkedAt.toISOString(), errors: [errorMessage] }; + } + + try { + const run = await insertDomainCheckRun({ + id: crypto.randomUUID(), domainId, checkedAt, result, error: errorMessage, + }); + + // Keep the existing enrichment field as the latest snapshot for compatibility. + // Preserve manually supplied expiry/registrar values; only fill missing metadata. + const suggested = result.suggested; + const suggestedExpiry = suggested?.expiresAt ? new Date(suggested.expiresAt) : null; + const expiry = domain.expiresAt ?? (suggestedExpiry && !Number.isNaN(suggestedExpiry.getTime()) ? suggestedExpiry : null); + await updateDomainForUser(domainId, userId, { + enrichment: result, + enrichedAt: checkedAt, + expiresAt: expiry, + registrarName: domain.registrarName ?? suggested?.registrarName ?? null, + updatedAt: checkedAt, + }); + + return { status: 200, body: { run: { + id: run.id, checkedAt: run.checkedAt.toISOString(), result: run.result, error: run.error, + } } }; + } catch (error) { + return { status: 500, body: { error: error instanceof Error ? error.message : "Database error" } }; + } +} + +export async function listDomainCheckRunsForUser( + userId: string | null, domainId: string, limitRaw?: string | null +): Promise { + if (!userId) return unauthorized(); + const limit = limitRaw ? Number(limitRaw) : HISTORY_LIMIT; + if (!Number.isInteger(limit) || limit < 1 || limit > 100) { + return { status: 400, body: { error: "limit must be an integer between 1 and 100" } }; + } + try { + const runs = await findDomainCheckRunsForUser(domainId, userId, limit); + if (!runs) return notFound(); + return { status: 200, body: { runs: runs.map((run) => ({ + id: run.id, checkedAt: run.checkedAt.toISOString(), result: run.result, error: run.error, + })) } }; + } catch (error) { + return { status: 500, body: { error: error instanceof Error ? error.message : "Database error" } }; + } +} From 2ccb738fa518a3236bfeff3a9a75f077c36d8c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:00 +0530 Subject: [PATCH 05/24] feat(domains): drizzle/0003_domain_check_runs.sql --- drizzle/0003_domain_check_runs.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 drizzle/0003_domain_check_runs.sql diff --git a/drizzle/0003_domain_check_runs.sql b/drizzle/0003_domain_check_runs.sql new file mode 100644 index 0000000..138fa9c --- /dev/null +++ b/drizzle/0003_domain_check_runs.sql @@ -0,0 +1,12 @@ +CREATE TABLE "domain_check_runs" ( + "id" text PRIMARY KEY NOT NULL, + "domainId" text NOT NULL, + "checkedAt" timestamp (3) DEFAULT now() NOT NULL, + "result" jsonb NOT NULL, + "error" text +); +--> statement-breakpoint +ALTER TABLE "domain_check_runs" ADD CONSTRAINT "domain_check_runs_domainId_domains_id_fk" + FOREIGN KEY ("domainId") REFERENCES "public"."domains"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "domain_check_runs_domainId_checkedAt_idx" ON "domain_check_runs" USING btree ("domainId", "checkedAt"); From 0fe2d2abc1617802f798d8489926e3d656a8eedd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:04 +0530 Subject: [PATCH 06/24] feat(domains): drizzle/meta/_journal.json --- drizzle/meta/_journal.json | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index d8a5c8a..ae42a0c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -2,26 +2,9 @@ "version": "7", "dialect": "postgresql", "entries": [ - { - "idx": 0, - "version": "7", - "when": 1774095492703, - "tag": "0000_spooky_odin", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1774437795200, - "tag": "0001_strange_jasper_sitwell", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1775055276544, - "tag": "0002_outstanding_bishop", - "breakpoints": true - } + { "idx": 0, "version": "7", "when": 1774095492703, "tag": "0000_spooky_odin", "breakpoints": true }, + { "idx": 1, "version": "7", "when": 1774437795200, "tag": "0001_strange_jasper_sitwell", "breakpoints": true }, + { "idx": 2, "version": "7", "when": 1775055276544, "tag": "0002_outstanding_bishop", "breakpoints": true }, + { "idx": 3, "version": "7", "when": 1793550000000, "tag": "0003_domain_check_runs", "breakpoints": true } ] -} \ No newline at end of file +} From 4e97b10cbefb4132bf6d4ca7504355eeca0f0477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:13 +0530 Subject: [PATCH 07/24] feat(domains): expose refresh and check history routes --- src/controllers/domain.controller.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/controllers/domain.controller.ts b/src/controllers/domain.controller.ts index 06daa9f..5062456 100644 --- a/src/controllers/domain.controller.ts +++ b/src/controllers/domain.controller.ts @@ -10,6 +10,7 @@ import { updateDomainForUserRequest, } from "../services/domain.service"; import { exportDomainsPortfolioResponse, importDomainsPortfolio } from "../services/domainPortfolio.service"; +import { listDomainCheckRunsForUser, refreshDomainCheckForUser } from "../services/domainCheck.service"; const domainOpenapi = { tags: ["domains"], @@ -91,6 +92,32 @@ export const domainRoutes = new Elysia().group("/api/domains", (app) => }, } ) + .post( + "/:id/checks", + async ({ request, params }) => + toResponse(await refreshDomainCheckForUser(await getUserIdFromRequest(request), params.id)), + { + detail: { + ...domainOpenapi, + summary: "Refresh domain checks", + description: "Run DNS, TLS, registration and host checks; append a snapshot to history. Limited to one refresh per domain per minute.", + }, + } + ) + .get( + "/:id/checks", + async ({ request, params }) => { + const limit = new URL(request.url).searchParams.get("limit"); + return toResponse(await listDomainCheckRunsForUser(await getUserIdFromRequest(request), params.id, limit)); + }, + { + detail: { + ...domainOpenapi, + summary: "List domain check history", + description: "Returns recent append-only check snapshots (default 20, maximum 100).", + }, + } + ) .get( "/:id", async ({ request, params }) => From 0840c03135020d8fd47b5e54b22121bcc8090074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:21 +0530 Subject: [PATCH 08/24] feat(domains): add frontend check history client --- frontend/src/pages/hooks/useDomains.ts | 33 ++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/frontend/src/pages/hooks/useDomains.ts b/frontend/src/pages/hooks/useDomains.ts index 54f25c7..95602ef 100644 --- a/frontend/src/pages/hooks/useDomains.ts +++ b/frontend/src/pages/hooks/useDomains.ts @@ -61,6 +61,39 @@ export async function deleteDomain( return { ok: true }; } +export type DomainCheckRun = { + id: string; + checkedAt: string; + result: DomainEnrichment; + error: string | null; +}; + +type CheckHistoryResponse = { runs: DomainCheckRun[] }; +type RefreshCheckResponse = { run: DomainCheckRun }; + +export async function fetchDomainCheckHistory(id: string, limit = 10): Promise< + { ok: true; runs: DomainCheckRun[] } | { ok: false; error: string } +> { + const res = await httpGet(`/api/domains/${encodeURIComponent(id)}/checks?limit=${limit}`); + if (!res.ok) return { ok: false, error: await readError(res) }; + const data = (await res.json()) as CheckHistoryResponse; + return { ok: true, runs: Array.isArray(data.runs) ? data.runs : [] }; +} + +export async function runDomainCheck(id: string): Promise< + { ok: true; run: DomainCheckRun } | { ok: false; error: string } +> { + try { + const res = await httpPost(`/api/domains/${encodeURIComponent(id)}/checks`, { json: {} }); + if (!res.ok) return { ok: false, error: await readError(res) }; + const data = (await res.json()) as RefreshCheckResponse; + if (!data.run) return { ok: false, error: "Invalid check response" }; + return { ok: true, run: data.run }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "Request failed" }; + } +} + type LookupResponse = { enrichment: DomainEnrichment }; export async function fetchDomainLookup(hostname: string): Promise< From 4ec9fd51b76b87109bbe6f480c78db00b7457e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:36 +0530 Subject: [PATCH 09/24] feat(domains): show persisted check history on detail page --- frontend/src/pages/domains/DomainDetail.tsx | 61 +++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/domains/DomainDetail.tsx b/frontend/src/pages/domains/DomainDetail.tsx index 5dec03b..7b16a85 100644 --- a/frontend/src/pages/domains/DomainDetail.tsx +++ b/frontend/src/pages/domains/DomainDetail.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { ArrowsClockwise, @@ -16,7 +16,7 @@ import { Trash, Warning, } from "@phosphor-icons/react"; -import { deleteDomain, refreshDomainPublic, useDomain } from "../hooks/useDomains"; +import { deleteDomain, fetchDomainCheckHistory, runDomainCheck, type DomainCheckRun, useDomain } from "../hooks/useDomains"; import { effectiveExpiry, effectiveRegistrar, @@ -134,6 +134,9 @@ export function DomainDetail() { const [deleting, setDeleting] = useState(false); const [refreshError, setRefreshError] = useState(null); const [refreshing, setRefreshing] = useState(false); + const [checkRuns, setCheckRuns] = useState([]); + const [historyError, setHistoryError] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); const deleteDialogRef = useRef(null); const enrichment = domain?.enrichment ?? null; @@ -161,17 +164,30 @@ export function DomainDetail() { [domain?.enrichedAt, enrichment?.fetchedAt] ); + async function loadCheckHistory() { + if (!id) return; + setHistoryLoading(true); + const response = await fetchDomainCheckHistory(id, 10); + setHistoryLoading(false); + if (!response.ok) { + setHistoryError(response.error); + return; + } + setHistoryError(null); + setCheckRuns(response.runs); + } + async function handleRefreshPublic() { if (!id) return; setRefreshError(null); setRefreshing(true); - const result = await refreshDomainPublic(id); + const result = await runDomainCheck(id); setRefreshing(false); if (!result.ok) { setRefreshError(result.error); return; } - await reload(); + await Promise.all([reload(), loadCheckHistory()]); } async function runDelete() { @@ -223,6 +239,10 @@ export function DomainDetail() { const snapshotTime = domain.enrichedAt ?? enrichment?.fetchedAt; + useEffect(() => { + void loadCheckHistory(); + }, [id]); + return (
+
+
+

Recent checks

+ +
+ {historyError ?

{historyError}

: null} + {!historyError && !checkRuns.length && !historyLoading ? ( +

No saved check runs yet. Choose “Refresh data” to create the first snapshot.

+ ) : null} + {checkRuns.length ? ( +
    + {checkRuns.map((run) => ( +
  1. + + + {run.error || run.result.errors?.length ? "Completed with warnings" : "Check completed"} + + +
    + View snapshot +
    +                        {JSON.stringify(run.result, null, 2)}
    +                      
    +
    +
  2. + ))} +
+ ) : null} +
From e6a0c63c84d201d42ba1425c5ff2f44310bee838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:35:51 +0530 Subject: [PATCH 10/24] fix(db): index domain check history queries --- src/db/schema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/db/schema.ts b/src/db/schema.ts index 221aeea..5a6e474 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -6,6 +6,7 @@ import { text, timestamp, unique, + index, } from "drizzle-orm/pg-core"; import type { DomainEnrichment } from "../domains/enrichment/types"; @@ -176,7 +177,7 @@ export const domainCheckRuns = pgTable("domain_check_runs", { checkedAt: timestamp("checkedAt", { mode: "date", precision: 3 }).notNull().defaultNow(), result: jsonb("result").$type().notNull(), error: text("error"), -}); +}, (t) => [index("domain_check_runs_domainId_checkedAt_idx").on(t.domainId, t.checkedAt)]); export const authSchema = { user, From cecaa3286d6b2ab748f5687cf9c47b0356d8edc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:36:01 +0530 Subject: [PATCH 11/24] fix(domains): load check history with effect lifecycle --- frontend/src/pages/domains/DomainDetail.tsx | 22 ++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/domains/DomainDetail.tsx b/frontend/src/pages/domains/DomainDetail.tsx index 7b16a85..6ee7262 100644 --- a/frontend/src/pages/domains/DomainDetail.tsx +++ b/frontend/src/pages/domains/DomainDetail.tsx @@ -150,6 +150,23 @@ export function DomainDetail() { const historyEvents = useMemo(() => sortRdapEventsDesc(rdap?.events ?? []), [rdap?.events]); + useEffect(() => { + if (!id) return; + let cancelled = false; + setHistoryLoading(true); + void fetchDomainCheckHistory(id, 10).then((response) => { + if (cancelled) return; + setHistoryLoading(false); + if (!response.ok) { + setHistoryError(response.error); + return; + } + setHistoryError(null); + setCheckRuns(response.runs); + }); + return () => { cancelled = true; }; + }, [id]); + const dataSourcesLine = useMemo(() => { const m = enrichment?.enrichmentMetadata; if (!m) return null; @@ -164,6 +181,7 @@ export function DomainDetail() { [domain?.enrichedAt, enrichment?.fetchedAt] ); + async function loadCheckHistory() { if (!id) return; setHistoryLoading(true); @@ -239,9 +257,7 @@ export function DomainDetail() { const snapshotTime = domain.enrichedAt ?? enrichment?.fetchedAt; - useEffect(() => { - void loadCheckHistory(); - }, [id]); + return (
From 40f94ad84c39140399f6951459bd1b0550cbc7cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:36:25 +0530 Subject: [PATCH 12/24] fix(domains): isolate lookup and refresh rate limits --- src/domains/enrichment/ratelimit.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/domains/enrichment/ratelimit.ts b/src/domains/enrichment/ratelimit.ts index cfe2511..299b933 100644 --- a/src/domains/enrichment/ratelimit.ts +++ b/src/domains/enrichment/ratelimit.ts @@ -3,7 +3,8 @@ const MAX_REQUESTS = 24; const DOMAIN_WINDOW_MS = 60_000; const MAX_DOMAIN_REQUESTS = 1; -const hits = new Map(); +const lookupHits = new Map(); +const checkHits = new Map(); const domainHits = new Map(); function consume(map: Map, key: string, max: number, windowMs: number): @@ -21,12 +22,12 @@ function consume(map: Map, key: string, max: number, windowMs: } export function rateLimitDomainLookup(userId: string) { - return consume(hits, userId, MAX_REQUESTS, WINDOW_MS); + return consume(lookupHits, userId, MAX_REQUESTS, WINDOW_MS); } -/** Keep explicit refreshes to one per domain per minute, in addition to the user-wide limit. */ +/** Limit explicit checks per user and per domain to keep outbound lookups bounded. */ export function rateLimitDomainCheck(userId: string, domainId: string) { - const user = consume(hits, userId, MAX_REQUESTS, WINDOW_MS); + const user = consume(checkHits, userId, MAX_REQUESTS, WINDOW_MS); if (!user.ok) return user; return consume(domainHits, `${userId}:${domainId}`, MAX_DOMAIN_REQUESTS, DOMAIN_WINDOW_MS); } From f9e18fa546695e707cc93c68c743a338f3db32b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:36:43 +0530 Subject: [PATCH 13/24] test(domains): cover refresh rate limits --- src/domains/enrichment/ratelimit.test.ts | 25 ++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 src/domains/enrichment/ratelimit.test.ts diff --git a/src/domains/enrichment/ratelimit.test.ts b/src/domains/enrichment/ratelimit.test.ts new file mode 100644 index 0000000..3c8293a --- /dev/null +++ b/src/domains/enrichment/ratelimit.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { rateLimitDomainCheck, rateLimitDomainLookup } from "./ratelimit"; + +describe("domain check rate limits", () => { + test("limits one explicit refresh per domain per minute", () => { + const user = crypto.randomUUID(); + const domain = crypto.randomUUID(); + expect(rateLimitDomainCheck(user, domain).ok).toBe(true); + const blocked = rateLimitDomainCheck(user, domain); + expect(blocked.ok).toBe(false); + if (!blocked.ok) expect(blocked.retryAfterSec).toBeGreaterThan(0); + }); + + test("allows a different domain for the same user", () => { + const user = crypto.randomUUID(); + expect(rateLimitDomainCheck(user, crypto.randomUUID()).ok).toBe(true); + expect(rateLimitDomainCheck(user, crypto.randomUUID()).ok).toBe(true); + }); + + test("keeps preview lookup allowance independent from explicit checks", () => { + const user = crypto.randomUUID(); + expect(rateLimitDomainCheck(user, crypto.randomUUID()).ok).toBe(true); + expect(rateLimitDomainLookup(user).ok).toBe(true); + }); +}); From f238ed7d8ccd1d5ac4b619ea35d6e0437d2cf1bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:36:53 +0530 Subject: [PATCH 14/24] docs(domains): document check snapshots API --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index c73efe0..98d629a 100644 --- a/README.md +++ b/README.md @@ -73,3 +73,15 @@ Set `BETTER_AUTH_SECRET` in `.env`. Either set `DATABASE_URL` (your Postgres; no --- + +## Domain checks and snapshots + +Domain detail pages support an on-demand public check. The refresh endpoint runs the existing DNS, TLS, registration and host enrichment pipeline and stores an append-only snapshot for troubleshooting. + +- `POST /api/domains/:id/checks` runs a refresh for a domain owned by the signed-in user. +- `GET /api/domains/:id/checks?limit=20` returns recent snapshots. The limit defaults to 20 and is capped at 100. +- Refreshes are session-authenticated, rate-limited to one check per domain per minute and 24 checks per user per minute (per application process). +- Snapshots are retained in `domain_check_runs`; deleting a domain cascades to its snapshots. The latest snapshot is also reflected in the existing `domains.enrichment` field for compatibility. +- Run `bun run db:migrate` before starting an upgraded deployment. + +The first version checks the domain’s DNS records, TLS certificate and existing registration/host enrichment. It does not run a separate HTTP status probe yet; scheduled portfolio-wide checks and webhook notifications are tracked separately. From 7a1c4b44605e604ab394f7957f2823127d26464a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:37:09 +0530 Subject: [PATCH 15/24] fix(domains): guard against concurrent duplicate checks --- src/domains/enrichment/ratelimit.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/domains/enrichment/ratelimit.ts b/src/domains/enrichment/ratelimit.ts index 299b933..3c0d5be 100644 --- a/src/domains/enrichment/ratelimit.ts +++ b/src/domains/enrichment/ratelimit.ts @@ -6,6 +6,7 @@ const MAX_DOMAIN_REQUESTS = 1; const lookupHits = new Map(); const checkHits = new Map(); const domainHits = new Map(); +const inFlightChecks = new Set(); function consume(map: Map, key: string, max: number, windowMs: number): { ok: true } | { ok: false; retryAfterSec: number } { @@ -31,3 +32,11 @@ export function rateLimitDomainCheck(userId: string, domainId: string) { if (!user.ok) return user; return consume(domainHits, `${userId}:${domainId}`, MAX_DOMAIN_REQUESTS, DOMAIN_WINDOW_MS); } + +/** Process-local overlap guard; prevents duplicate concurrent work for one domain. */ +export function acquireDomainCheck(userId: string, domainId: string): (() => void) | null { + const key = `${userId}:${domainId}`; + if (inFlightChecks.has(key)) return null; + inFlightChecks.add(key); + return () => inFlightChecks.delete(key); +} From 289b49705786283d42254fe53d66a68f2ed52843 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:37:19 +0530 Subject: [PATCH 16/24] fix(domains): release in-flight guard after refresh --- src/services/domainCheck.service.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index 7c4c780..7d37b2e 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -3,7 +3,7 @@ import { enrichDomain } from "../domains/enrichment/service"; import { enrichmentToStorable } from "../models/domain.model"; import { findDomainByIdForUser, updateDomainForUser } from "../repositories/domain.repository"; import { findDomainCheckRunsForUser, insertDomainCheckRun } from "../repositories/domainCheckRun.repository"; -import { rateLimitDomainCheck } from "../domains/enrichment/ratelimit"; +import { acquireDomainCheck, rateLimitDomainCheck } from "../domains/enrichment/ratelimit"; const HISTORY_LIMIT = 20; @@ -22,8 +22,11 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: } if (!domain) return notFound(); + const releaseCheck = acquireDomainCheck(userId, domainId); + if (!releaseCheck) return { status: 409, body: { error: "A check for this domain is already running." } }; const limited = rateLimitDomainCheck(userId, domainId); if (!limited.ok) { + releaseCheck(); return { status: 429, body: { error: "This domain was checked recently. Try again shortly.", retryAfter: limited.retryAfterSec, @@ -63,6 +66,8 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: } } }; } catch (error) { return { status: 500, body: { error: error instanceof Error ? error.message : "Database error" } }; + } finally { + releaseCheck(); } } From e13acdf8949ec9c5e54c98b152959aa06d920da4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:37:33 +0530 Subject: [PATCH 17/24] fix(domains): commit snapshots and latest state atomically --- src/services/domainCheck.service.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index 7d37b2e..bc9a477 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -44,21 +44,25 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: } try { - const run = await insertDomainCheckRun({ - id: crypto.randomUUID(), domainId, checkedAt, result, error: errorMessage, - }); - - // Keep the existing enrichment field as the latest snapshot for compatibility. - // Preserve manually supplied expiry/registrar values; only fill missing metadata. + // The append-only history row and latest snapshot update commit or roll back together. + const { db } = await import("../db/client"); + const { domains, domainCheckRuns } = await import("../db/schema"); + const { and, eq } = await import("drizzle-orm"); const suggested = result.suggested; const suggestedExpiry = suggested?.expiresAt ? new Date(suggested.expiresAt) : null; const expiry = domain.expiresAt ?? (suggestedExpiry && !Number.isNaN(suggestedExpiry.getTime()) ? suggestedExpiry : null); - await updateDomainForUser(domainId, userId, { - enrichment: result, - enrichedAt: checkedAt, - expiresAt: expiry, - registrarName: domain.registrarName ?? suggested?.registrarName ?? null, - updatedAt: checkedAt, + const [run] = await db.transaction(async (tx) => { + const [inserted] = await tx.insert(domainCheckRuns).values({ + id: crypto.randomUUID(), domainId, checkedAt, result, error: errorMessage, + }).returning(); + await tx.update(domains).set({ + enrichment: result, + enrichedAt: checkedAt, + expiresAt: expiry, + registrarName: domain.registrarName ?? suggested?.registrarName ?? null, + updatedAt: checkedAt, + }).where(and(eq(domains.id, domainId), eq(domains.userId, userId))); + return [inserted]; }); return { status: 200, body: { run: { From 7094aebfe0c59e9e7178fceeed69363b6b73d6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:37:46 +0530 Subject: [PATCH 18/24] refactor(domains): use static transaction imports --- src/services/domainCheck.service.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index bc9a477..eae373e 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -1,9 +1,12 @@ import type { ApiResult } from "../types/api"; import { enrichDomain } from "../domains/enrichment/service"; import { enrichmentToStorable } from "../models/domain.model"; -import { findDomainByIdForUser, updateDomainForUser } from "../repositories/domain.repository"; -import { findDomainCheckRunsForUser, insertDomainCheckRun } from "../repositories/domainCheckRun.repository"; +import { findDomainByIdForUser } from "../repositories/domain.repository"; +import { findDomainCheckRunsForUser } from "../repositories/domainCheckRun.repository"; import { acquireDomainCheck, rateLimitDomainCheck } from "../domains/enrichment/ratelimit"; +import { and, eq } from "drizzle-orm"; +import { db } from "../db/client"; +import { domainCheckRuns, domains } from "../db/schema"; const HISTORY_LIMIT = 20; @@ -45,9 +48,6 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: try { // The append-only history row and latest snapshot update commit or roll back together. - const { db } = await import("../db/client"); - const { domains, domainCheckRuns } = await import("../db/schema"); - const { and, eq } = await import("drizzle-orm"); const suggested = result.suggested; const suggestedExpiry = suggested?.expiresAt ? new Date(suggested.expiresAt) : null; const expiry = domain.expiresAt ?? (suggestedExpiry && !Number.isNaN(suggestedExpiry.getTime()) ? suggestedExpiry : null); From 8bb50ffd8cd345e1df566a2f0d59f4bc197d0648 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:37:54 +0530 Subject: [PATCH 19/24] fix(domains): sanitize check failure responses --- src/services/domainCheck.service.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index eae373e..2f3c341 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -13,6 +13,8 @@ const HISTORY_LIMIT = 20; function unauthorized(): ApiResult { return { status: 401, body: { error: "Unauthorized" } }; } function notFound(): ApiResult { return { status: 404, body: { error: "Not found" } }; } +function checkFailed(): ApiResult { return { status: 503, body: { error: "Domain check could not be completed" } }; } + export async function refreshDomainCheckForUser(userId: string | null, domainId: string): Promise { if (!userId) return unauthorized(); if (!domainId) return notFound(); @@ -41,8 +43,9 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: let errorMessage: string | null = null; try { result = enrichmentToStorable(await enrichDomain(domain.hostname)); - } catch (error) { - errorMessage = error instanceof Error ? error.message : "Domain check failed"; + } catch { + // Record only sanitized failure information; raw dependency errors may contain URLs or details. + errorMessage = "Domain check failed"; result = { fetchedAt: checkedAt.toISOString(), errors: [errorMessage] }; } @@ -68,8 +71,8 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: return { status: 200, body: { run: { id: run.id, checkedAt: run.checkedAt.toISOString(), result: run.result, error: run.error, } } }; - } catch (error) { - return { status: 500, body: { error: error instanceof Error ? error.message : "Database error" } }; + } catch { + return { status: 500, body: { error: "Could not persist domain check snapshot" } }; } finally { releaseCheck(); } From 042fa7f9986392743c3b99ce83697ea893d2984a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:38:00 +0530 Subject: [PATCH 20/24] chore(domains): remove unused response helper --- src/services/domainCheck.service.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index 2f3c341..1795d41 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -13,7 +13,6 @@ const HISTORY_LIMIT = 20; function unauthorized(): ApiResult { return { status: 401, body: { error: "Unauthorized" } }; } function notFound(): ApiResult { return { status: 404, body: { error: "Not found" } }; } -function checkFailed(): ApiResult { return { status: 503, body: { error: "Domain check could not be completed" } }; } export async function refreshDomainCheckForUser(userId: string | null, domainId: string): Promise { if (!userId) return unauthorized(); From 8a4bd4c8f9c3598b43aba522cf8b9a4752d29e61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:38:07 +0530 Subject: [PATCH 21/24] refactor(domains): keep only history read repository helper --- src/repositories/domainCheckRun.repository.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/repositories/domainCheckRun.repository.ts b/src/repositories/domainCheckRun.repository.ts index 0fec94a..183c723 100644 --- a/src/repositories/domainCheckRun.repository.ts +++ b/src/repositories/domainCheckRun.repository.ts @@ -5,17 +5,6 @@ import type { DomainEnrichment } from "../domains/enrichment/types"; export type DomainCheckRun = typeof domainCheckRuns.$inferSelect; -export async function insertDomainCheckRun(input: { - id: string; - domainId: string; - checkedAt: Date; - result: DomainEnrichment; - error?: string | null; -}): Promise { - const [row] = await db.insert(domainCheckRuns).values(input).returning(); - return row; -} - export async function findDomainCheckRunsForUser(domainId: string, userId: string, limit = 20) { const [owned] = await db.select({ id: domains.id }).from(domains) .where(and(eq(domains.id, domainId), eq(domains.userId, userId))).limit(1); From de5f5a7c72e57051976343a5de9894e06cd7f42e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:38:17 +0530 Subject: [PATCH 22/24] test(domains): verify overlapping refresh guard --- src/domains/enrichment/ratelimit.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/domains/enrichment/ratelimit.test.ts b/src/domains/enrichment/ratelimit.test.ts index 3c8293a..9ba5170 100644 --- a/src/domains/enrichment/ratelimit.test.ts +++ b/src/domains/enrichment/ratelimit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { rateLimitDomainCheck, rateLimitDomainLookup } from "./ratelimit"; +import { acquireDomainCheck, rateLimitDomainCheck, rateLimitDomainLookup } from "./ratelimit"; describe("domain check rate limits", () => { test("limits one explicit refresh per domain per minute", () => { @@ -22,4 +22,14 @@ describe("domain check rate limits", () => { expect(rateLimitDomainCheck(user, crypto.randomUUID()).ok).toBe(true); expect(rateLimitDomainLookup(user).ok).toBe(true); }); + + test("prevents overlapping checks for the same domain and releases the lock", () => { + const user = crypto.randomUUID(); + const domain = crypto.randomUUID(); + const release = acquireDomainCheck(user, domain); + expect(release).toBeFunction(); + expect(acquireDomainCheck(user, domain)).toBeNull(); + release?.(); + expect(acquireDomainCheck(user, domain)).toBeFunction(); + }); }); From a88b904eaf56ba8ef62c6313aea9f6418b177d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:39:00 +0530 Subject: [PATCH 23/24] test(domains): use portable function assertions --- src/domains/enrichment/ratelimit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/domains/enrichment/ratelimit.test.ts b/src/domains/enrichment/ratelimit.test.ts index 9ba5170..f064ad9 100644 --- a/src/domains/enrichment/ratelimit.test.ts +++ b/src/domains/enrichment/ratelimit.test.ts @@ -27,9 +27,11 @@ describe("domain check rate limits", () => { const user = crypto.randomUUID(); const domain = crypto.randomUUID(); const release = acquireDomainCheck(user, domain); - expect(release).toBeFunction(); + expect(typeof release).toBe("function"); expect(acquireDomainCheck(user, domain)).toBeNull(); release?.(); - expect(acquireDomainCheck(user, domain)).toBeFunction(); + const reacquired = acquireDomainCheck(user, domain); + expect(typeof reacquired).toBe("function"); + reacquired?.(); }); }); From 4960e3cda7100802ce9e59c98a4aa271463a4c62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?shravan=20=7C=7C=20=E0=A4=B6=E0=A5=8D=E0=A4=B0van?= Date: Sun, 1 Nov 2026 16:39:08 +0530 Subject: [PATCH 24/24] refactor(domains): type persisted check snapshots --- src/services/domainCheck.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/domainCheck.service.ts b/src/services/domainCheck.service.ts index 1795d41..e6016a1 100644 --- a/src/services/domainCheck.service.ts +++ b/src/services/domainCheck.service.ts @@ -1,4 +1,5 @@ import type { ApiResult } from "../types/api"; +import type { DomainEnrichment } from "../domains/enrichment/types"; import { enrichDomain } from "../domains/enrichment/service"; import { enrichmentToStorable } from "../models/domain.model"; import { findDomainByIdForUser } from "../repositories/domain.repository"; @@ -38,7 +39,7 @@ export async function refreshDomainCheckForUser(userId: string | null, domainId: } const checkedAt = new Date(); - let result; + let result: DomainEnrichment; let errorMessage: string | null = null; try { result = enrichmentToStorable(await enrichDomain(domain.hostname));