Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5b60932
feat(domains): add snapshot run repository
zhravan Nov 1, 2026
c46c678
feat(domains): model append-only check snapshots
zhravan Nov 1, 2026
ff35b69
feat(domains): src/domains/enrichment/ratelimit.ts
zhravan Nov 1, 2026
c094707
feat(domains): src/services/domainCheck.service.ts
zhravan Nov 1, 2026
2ccb738
feat(domains): drizzle/0003_domain_check_runs.sql
zhravan Nov 1, 2026
0fe2d2a
feat(domains): drizzle/meta/_journal.json
zhravan Nov 1, 2026
4e97b10
feat(domains): expose refresh and check history routes
zhravan Nov 1, 2026
0840c03
feat(domains): add frontend check history client
zhravan Nov 1, 2026
4ec9fd5
feat(domains): show persisted check history on detail page
zhravan Nov 1, 2026
e6a0c63
fix(db): index domain check history queries
zhravan Nov 1, 2026
cecaa32
fix(domains): load check history with effect lifecycle
zhravan Nov 1, 2026
40f94ad
fix(domains): isolate lookup and refresh rate limits
zhravan Nov 1, 2026
f9e18fa
test(domains): cover refresh rate limits
zhravan Nov 1, 2026
f238ed7
docs(domains): document check snapshots API
zhravan Nov 1, 2026
7a1c4b4
fix(domains): guard against concurrent duplicate checks
zhravan Nov 1, 2026
289b497
fix(domains): release in-flight guard after refresh
zhravan Nov 1, 2026
e13acdf
fix(domains): commit snapshots and latest state atomically
zhravan Nov 1, 2026
7094aeb
refactor(domains): use static transaction imports
zhravan Nov 1, 2026
8bb50ff
fix(domains): sanitize check failure responses
zhravan Nov 1, 2026
042fa7f
chore(domains): remove unused response helper
zhravan Nov 1, 2026
8a4bd4c
refactor(domains): keep only history read repository helper
zhravan Nov 1, 2026
de5f5a7
test(domains): verify overlapping refresh guard
zhravan Nov 1, 2026
a88b904
test(domains): use portable function assertions
zhravan Nov 1, 2026
4960e3c
refactor(domains): type persisted check snapshots
zhravan Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,15 @@ Set `BETTER_AUTH_SECRET` in `.env`. Either set `DATABASE_URL` (your Postgres; no
---

</samp>

## 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.
12 changes: 12 additions & 0 deletions drizzle/0003_domain_check_runs.sql
Original file line number Diff line number Diff line change
@@ -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");
27 changes: 5 additions & 22 deletions drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
]
}
}
77 changes: 73 additions & 4 deletions frontend/src/pages/domains/DomainDetail.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -134,6 +134,9 @@ export function DomainDetail() {
const [deleting, setDeleting] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);
const [checkRuns, setCheckRuns] = useState<DomainCheckRun[]>([]);
const [historyError, setHistoryError] = useState<string | null>(null);
const [historyLoading, setHistoryLoading] = useState(false);
const deleteDialogRef = useRef<ConfirmDialogHandle>(null);

const enrichment = domain?.enrichment ?? null;
Expand All @@ -147,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;
Expand All @@ -161,17 +181,31 @@ 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() {
Expand Down Expand Up @@ -223,6 +257,8 @@ export function DomainDetail() {

const snapshotTime = domain.enrichedAt ?? enrichment?.fetchedAt;



return (
<div className="pd-domain-detail">
<ConfirmDialog
Expand Down Expand Up @@ -511,6 +547,39 @@ export function DomainDetail() {
Portfolio updated {formatDateTimeShort(domain.updatedAt)} · Public snapshot{" "}
{formatDateTimeShort(domain.enrichedAt ?? enrichment?.fetchedAt)}
</footer>
<div className="pd-domain-detail__check-history" aria-label="Recent check runs">
<div className="pd-domain-detail__panel-head">
<h3>Recent checks</h3>
<button type="button" className="small pd-domain-detail__btn-ghost" onClick={() => void loadCheckHistory()} disabled={historyLoading}>
{historyLoading ? "Loading…" : "Reload history"}
</button>
</div>
{historyError ? <p role="alert" className="pd-domain-detail__muted">{historyError}</p> : null}
{!historyError && !checkRuns.length && !historyLoading ? (
<p className="pd-domain-detail__muted">No saved check runs yet. Choose “Refresh data” to create the first snapshot.</p>
) : null}
{checkRuns.length ? (
<ol className="pd-domain-detail__timeline">
{checkRuns.map((run) => (
<li key={run.id}>
<span className="pd-domain-detail__timeline-dot" aria-hidden />
<span className="pd-domain-detail__timeline-action">
{run.error || run.result.errors?.length ? "Completed with warnings" : "Check completed"}
</span>
<time className="pd-domain-detail__timeline-time" dateTime={run.checkedAt}>
{formatDateTimeShort(run.checkedAt)}
</time>
<details>
<summary>View snapshot</summary>
<pre style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere", fontSize: "var(--text-8)" }}>
{JSON.stringify(run.result, null, 2)}
</pre>
</details>
</li>
))}
</ol>
) : null}
</div>
</section>
</div>
</div>
Expand Down
33 changes: 33 additions & 0 deletions frontend/src/pages/hooks/useDomains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down
27 changes: 27 additions & 0 deletions src/controllers/domain.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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 }) =>
Expand Down
10 changes: 10 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
text,
timestamp,
unique,
index,
} from "drizzle-orm/pg-core";
import type { DomainEnrichment } from "../domains/enrichment/types";

Expand Down Expand Up @@ -169,6 +170,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<DomainEnrichment>().notNull(),
error: text("error"),
}, (t) => [index("domain_check_runs_domainId_checkedAt_idx").on(t.domainId, t.checkedAt)]);

export const authSchema = {
user,
session,
Expand Down
37 changes: 37 additions & 0 deletions src/domains/enrichment/ratelimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import { acquireDomainCheck, 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);
});

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(typeof release).toBe("function");
expect(acquireDomainCheck(user, domain)).toBeNull();
release?.();
const reacquired = acquireDomainCheck(user, domain);
expect(typeof reacquired).toBe("function");
reacquired?.();
});
});
40 changes: 32 additions & 8 deletions src/domains/enrichment/ratelimit.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,42 @@
const WINDOW_MS = 60_000;
const MAX_REQUESTS = 24;
const DOMAIN_WINDOW_MS = 60_000;
const MAX_DOMAIN_REQUESTS = 1;

const hits = new Map<string, number[]>();
const lookupHits = new Map<string, number[]>();
const checkHits = new Map<string, number[]>();
const domainHits = new Map<string, number[]>();
const inFlightChecks = new Set<string>();

export function rateLimitDomainLookup(userId: string): { ok: true } | { ok: false; retryAfterSec: number } {
function consume(map: Map<string, number[]>, 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(lookupHits, userId, MAX_REQUESTS, WINDOW_MS);
}

/** Limit explicit checks per user and per domain to keep outbound lookups bounded. */
export function rateLimitDomainCheck(userId: string, domainId: string) {
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);
}

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