diff --git a/apps/web/app/api/cron/cleanup-stale-domains/route.ts b/apps/web/app/api/cron/cleanup-stale-domains/route.ts index 82aea5c5..36fe4e22 100644 --- a/apps/web/app/api/cron/cleanup-stale-domains/route.ts +++ b/apps/web/app/api/cron/cleanup-stale-domains/route.ts @@ -1,4 +1,3 @@ -import { subDays } from "date-fns"; import { NextResponse } from "next/server"; import { deleteStaleUnverifiedDomainsByCutoff } from "@domainstack/db/queries/tracked-domains"; @@ -8,6 +7,7 @@ const logger = createLogger({ source: "cron/cleanup-stale-domains" }); // Domains that remain unverified after this many days will be deleted const STALE_DOMAIN_DAYS = 30; +const MS_PER_DAY = 24 * 60 * 60 * 1000; /** * Cron job to clean up stale unverified domains. @@ -25,7 +25,9 @@ export async function GET(request: Request) { try { logger.info("Starting cleanup stale domains cron job"); - const cutoffDate = subDays(new Date(), STALE_DOMAIN_DAYS); + // Absolute rather than calendar days: an hour either side of a clock + // change is immaterial to a month-long staleness cutoff. + const cutoffDate = new Date(Date.now() - STALE_DOMAIN_DAYS * MS_PER_DAY); // Optimized: Delete directly by cutoff date in a single query const deletedCount = await deleteStaleUnverifiedDomainsByCutoff(cutoffDate); diff --git a/apps/web/app/api/transport/[transport]/route.ts b/apps/web/app/api/transport/[transport]/route.ts index 8c176b26..dbfee42f 100644 --- a/apps/web/app/api/transport/[transport]/route.ts +++ b/apps/web/app/api/transport/[transport]/route.ts @@ -4,6 +4,7 @@ import { createMcpHandler } from "mcp-handler"; import { PostHog } from "posthog-node"; import { z } from "zod"; +import { type Section, SECTION_IDS } from "@/lib/constants/sections"; import { checkRateLimit } from "@/lib/ratelimit/api"; import { createCaller } from "@/server/routers/_app"; import type { Context } from "@/trpc/init"; @@ -27,22 +28,8 @@ const domainSchema = z.object({ domain: z.string().min(1, "Domain is required"), }); -/** - * Available sections for domain_report bundle tool. - */ -const REPORT_SECTIONS = [ - "dns", - "registration", - "hosting", - "certificates", - "headers", - "seo", -] as const; - -type ReportSection = (typeof REPORT_SECTIONS)[number]; - const sectionsSchema = z - .array(z.enum(REPORT_SECTIONS)) + .array(z.enum(SECTION_IDS)) .optional() .describe("Sections to include in the report. If omitted, all sections are included."); @@ -225,12 +212,12 @@ function createMcpHandlerWithContext(request: Request) { }, async ({ domain, sections }) => { // Default to all sections if not specified - const requestedSections: ReportSection[] = - sections && sections.length > 0 ? sections : [...REPORT_SECTIONS]; + const requestedSections: Section[] = + sections && sections.length > 0 ? sections : [...SECTION_IDS]; // Define section fetchers const sectionFetchers: Record< - ReportSection, + Section, () => Promise<{ success: boolean; data?: unknown; error?: string }> > = { registration: () => trpc.domain.getRegistration({ domain }), diff --git a/apps/web/components/calendar-instructions.tsx b/apps/web/components/calendar-instructions.tsx index af231127..5a5b8f98 100644 --- a/apps/web/components/calendar-instructions.tsx +++ b/apps/web/components/calendar-instructions.tsx @@ -8,7 +8,6 @@ import { IconRefresh, IconShieldLock, } from "@tabler/icons-react"; -import { formatDistanceStrict } from "date-fns"; import { useCallback, useState } from "react"; import { useCalendarFeed } from "@/hooks/use-calendar-feed"; @@ -35,7 +34,7 @@ import { } from "@domainstack/ui/dropdown-menu"; import { Skeleton } from "@domainstack/ui/skeleton"; import { Spinner } from "@domainstack/ui/spinner"; -import { toDateTimeAttr } from "@domainstack/utils/date"; +import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; /** * Outlook doesn't have an icon in @icons-pack/react-simple-icons, so we draw @@ -140,11 +139,7 @@ export function CalendarInstructions({ className }: { className?: string }) { Last accessed{" "} - {now - ? formatDistanceStrict(new Date(lastAccessedDateTime), now, { - addSuffix: true, - }) - : "…"} + {now ? formatRelativeTime(new Date(lastAccessedDateTime), now) : "…"} ) : ( diff --git a/apps/web/components/dashboard/archived-domains-list.tsx b/apps/web/components/dashboard/archived-domains-list.tsx index fd0720b7..662a668e 100644 --- a/apps/web/components/dashboard/archived-domains-list.tsx +++ b/apps/web/components/dashboard/archived-domains-list.tsx @@ -1,5 +1,4 @@ import { IconArchive, IconCircleArrowUp, IconRefresh, IconTrash } from "@tabler/icons-react"; -import { formatDistanceStrict } from "date-fns"; import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable"; import { Favicon } from "@/components/icons/favicon"; @@ -18,7 +17,7 @@ import { } from "@domainstack/ui/empty"; import { Tooltip, TooltipContent, TooltipTrigger } from "@domainstack/ui/tooltip"; import { cn } from "@domainstack/ui/utils"; -import { toDateTimeAttr } from "@domainstack/utils/date"; +import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; type ArchivedDomainsListProps = { domains: TrackedDomainWithDetails[]; @@ -122,7 +121,7 @@ function ArchivedRelativeTime({ archivedAt }: { archivedAt: Date | string | null return recently; } - const label = now ? formatDistanceStrict(new Date(dateTime), now, { addSuffix: true }) : null; + const label = now ? formatRelativeTime(new Date(dateTime), now) : null; return ( 0) { diff --git a/apps/web/components/dashboard/subscription-ending-banner.tsx b/apps/web/components/dashboard/subscription-ending-banner.tsx index ebff0a8f..9ac86ea2 100644 --- a/apps/web/components/dashboard/subscription-ending-banner.tsx +++ b/apps/web/components/dashboard/subscription-ending-banner.tsx @@ -1,11 +1,11 @@ import { IconCalendarDot } from "@tabler/icons-react"; -import { differenceInDays, formatDistanceStrict } from "date-fns"; import { DashboardBannerDismissable } from "@/components/dashboard/dashboard-banner-dismissable"; import { useHydratedNow } from "@/hooks/use-hydrated-now"; import { useSubscription } from "@/hooks/use-subscription"; import { PLAN_QUOTAS } from "@domainstack/constants"; -import { formatDate, toDateTimeAttr } from "@domainstack/utils/date"; +import { formatDate, formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; +import { calculateDaysRemaining } from "@domainstack/utils/expiry"; export function SubscriptionEndingBanner() { const { handleCheckout, isCheckoutLoading, handleCustomerPortal, isCustomerPortalLoading } = @@ -25,11 +25,9 @@ export function SubscriptionEndingBanner() { const isExpired = subscription.endsAt < now; if (isExpired) return null; - const daysRemaining = differenceInDays(subscription.endsAt, now); + const daysRemaining = calculateDaysRemaining(subscription.endsAt, now); const formattedDate = formatDate(subscription.endsAt); - const relativeTime = formatDistanceStrict(subscription.endsAt, now, { - addSuffix: true, - }); + const relativeTime = formatRelativeTime(subscription.endsAt, now); // Determine urgency based on days remaining const isUrgent = daysRemaining <= 3; diff --git a/apps/web/components/domain/relative-age.tsx b/apps/web/components/domain/relative-age.tsx index 0b0c751d..88834c30 100644 --- a/apps/web/components/domain/relative-age.tsx +++ b/apps/web/components/domain/relative-age.tsx @@ -1,11 +1,10 @@ "use client"; -import { formatDistanceStrict } from "date-fns"; import { useMemo } from "react"; import { RelativeTimeSuffix } from "@/components/domain/relative-time-suffix"; import { useHydratedNow } from "@/hooks/use-hydrated-now"; -import { toDateTimeAttr } from "@domainstack/utils/date"; +import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; export function RelativeAgeString({ from, @@ -21,7 +20,7 @@ export function RelativeAgeString({ const text = useMemo(() => { if (!now || !dateTime) return null; - return formatDistanceStrict(new Date(dateTime), now, { addSuffix: true }); + return formatRelativeTime(new Date(dateTime), now) ?? null; }, [dateTime, now]); return ; diff --git a/apps/web/components/domain/relative-expiry.tsx b/apps/web/components/domain/relative-expiry.tsx index adc7a6e9..a7ba3a15 100644 --- a/apps/web/components/domain/relative-expiry.tsx +++ b/apps/web/components/domain/relative-expiry.tsx @@ -1,12 +1,12 @@ "use client"; -import { differenceInDays, formatDistanceStrict } from "date-fns"; import { useMemo } from "react"; import { RelativeTimeSuffix } from "@/components/domain/relative-time-suffix"; import { useHydratedNow } from "@/hooks/use-hydrated-now"; import { cn } from "@domainstack/ui/utils"; -import { toDateTimeAttr } from "@domainstack/utils/date"; +import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; +import { calculateDaysRemaining } from "@domainstack/utils/expiry"; export function RelativeExpiryString({ to, @@ -30,8 +30,8 @@ export function RelativeExpiryString({ if (!now || !dateTime) return null; const targetDate = new Date(dateTime); return { - text: formatDistanceStrict(targetDate, now, { addSuffix: true }), - daysUntil: differenceInDays(targetDate, now), + text: formatRelativeTime(targetDate, now), + daysUntil: calculateDaysRemaining(targetDate, now), }; }, [dateTime, now]); diff --git a/apps/web/components/notifications/notification-card.tsx b/apps/web/components/notifications/notification-card.tsx index f686a2a1..c346938f 100644 --- a/apps/web/components/notifications/notification-card.tsx +++ b/apps/web/components/notifications/notification-card.tsx @@ -1,6 +1,5 @@ "use client"; -import { formatDistanceStrict } from "date-fns"; import Link from "next/link"; import { createElement } from "react"; @@ -14,7 +13,7 @@ import { import type { NotificationData } from "@domainstack/types"; import { Icon } from "@domainstack/ui/icon"; import { cn } from "@domainstack/ui/utils"; -import { toDateTimeAttr } from "@domainstack/utils/date"; +import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date"; interface NotificationCardProps { notification: NotificationData; @@ -69,11 +68,7 @@ export function NotificationCard({ notification, onClick }: NotificationCardProp {sentAtDateTime ? ( - {now - ? formatDistanceStrict(new Date(sentAtDateTime), now, { - addSuffix: true, - }) - : "…"} + {now ? formatRelativeTime(new Date(sentAtDateTime), now) : "…"} ) : null} diff --git a/apps/web/lib/chat/domain-tools.test.ts b/apps/web/lib/chat/domain-tools.test.ts index 5548e965..fcd7727a 100644 --- a/apps/web/lib/chat/domain-tools.test.ts +++ b/apps/web/lib/chat/domain-tools.test.ts @@ -7,8 +7,6 @@ import { getDomainToolErrorMessage, getDomainToolStatus, getToolPartType, - getTrpcErrorCode, - isExpectedDomainToolError, type DomainToolResult, } from "./domain-tools"; @@ -57,37 +55,6 @@ describe("getDomainToolStatus", () => { }); }); -describe("getTrpcErrorCode", () => { - it("returns a direct tRPC code", () => { - expect(getTrpcErrorCode({ code: "BAD_REQUEST" })).toBe("BAD_REQUEST"); - }); - - it("returns a nested data.code tRPC code", () => { - expect(getTrpcErrorCode({ data: { code: "TOO_MANY_REQUESTS" } })).toBe("TOO_MANY_REQUESTS"); - }); - - it("returns undefined for non-object inputs", () => { - expect(getTrpcErrorCode("BAD_REQUEST")).toBeUndefined(); - expect(getTrpcErrorCode(null)).toBeUndefined(); - }); - - it("ignores generic string codes that are not tRPC codes", () => { - expect(getTrpcErrorCode({ code: "ENOTFOUND" })).toBeUndefined(); - }); -}); - -describe("isExpectedDomainToolError", () => { - it("treats validation and rate-limit failures as expected", () => { - expect(isExpectedDomainToolError({ code: "BAD_REQUEST" })).toBe(true); - expect(isExpectedDomainToolError({ data: { code: "TOO_MANY_REQUESTS" } })).toBe(true); - }); - - it("treats internal and unknown failures as unexpected", () => { - expect(isExpectedDomainToolError({ code: "INTERNAL_SERVER_ERROR" })).toBe(false); - expect(isExpectedDomainToolError(new Error("boom"))).toBe(false); - }); -}); - describe("getDomainToolErrorMessage", () => { it("returns the TOO_MANY_REQUESTS message", () => { expect(getDomainToolErrorMessage({ code: "TOO_MANY_REQUESTS" })).toBe( diff --git a/apps/web/lib/chat/domain-tools.ts b/apps/web/lib/chat/domain-tools.ts index 37c2e48b..83cb05e2 100644 --- a/apps/web/lib/chat/domain-tools.ts +++ b/apps/web/lib/chat/domain-tools.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { getTrpcErrorCode } from "@/lib/trpc/errors"; import type { CertificatesResponse, DnsRecordsResponse, @@ -86,30 +87,6 @@ const DOMAIN_TOOL_STATUS = Object.fromEntries( DOMAIN_TOOL_DEFS.map((def) => [def.name, def.status]), ) as Record; -const TRPC_ERROR_CODES = new Set([ - "PARSE_ERROR", - "BAD_REQUEST", - "INTERNAL_SERVER_ERROR", - "NOT_IMPLEMENTED", - "BAD_GATEWAY", - "SERVICE_UNAVAILABLE", - "GATEWAY_TIMEOUT", - "UNAUTHORIZED", - "PAYMENT_REQUIRED", - "FORBIDDEN", - "NOT_FOUND", - "METHOD_NOT_SUPPORTED", - "TIMEOUT", - "CONFLICT", - "PRECONDITION_FAILED", - "PAYLOAD_TOO_LARGE", - "UNSUPPORTED_MEDIA_TYPE", - "UNPROCESSABLE_CONTENT", - "PRECONDITION_REQUIRED", - "TOO_MANY_REQUESTS", - "CLIENT_CLOSED_REQUEST", -]); - export type DomainToolInput = z.infer; /** @@ -133,38 +110,6 @@ export function getDomainToolStatus(type: string): string { return DOMAIN_TOOL_STATUS[toolName as DomainToolName] ?? toolName; } -function asTrpcErrorCode(code: unknown): string | undefined { - return typeof code === "string" && TRPC_ERROR_CODES.has(code) ? code : undefined; -} - -/** - * tRPC failures the model can report to the user (validation, rate limits). - * Unexpected / internal errors should be retried by the workflow step instead. - */ -export function isExpectedDomainToolError(err: unknown): boolean { - const code = getTrpcErrorCode(err); - return code != null && code !== "INTERNAL_SERVER_ERROR"; -} - -export function getTrpcErrorCode(err: unknown): string | undefined { - if (typeof err !== "object" || err === null) { - return undefined; - } - - if ("code" in err) { - const code = asTrpcErrorCode(err.code); - if (code) { - return code; - } - } - - if ("data" in err && typeof err.data === "object" && err.data !== null && "code" in err.data) { - return asTrpcErrorCode(err.data.code); - } - - return undefined; -} - export function getDomainToolErrorMessage(err: unknown): string { const code = getTrpcErrorCode(err); if (code === "TOO_MANY_REQUESTS") { diff --git a/apps/web/lib/constants/sections.ts b/apps/web/lib/constants/sections.ts index 62e610e7..f99d3ce7 100644 --- a/apps/web/lib/constants/sections.ts +++ b/apps/web/lib/constants/sections.ts @@ -11,7 +11,21 @@ import { * Section types and metadata for domain report UI. */ -export type Section = "dns" | "headers" | "hosting" | "certificates" | "seo" | "registration"; +/** + * Every report section, in the order the report renders them. The `Section` + * union and the `sections` record below both derive from this, so anything that + * needs the full list at runtime can import it instead of retyping it. + */ +export const SECTION_IDS = [ + "registration", + "hosting", + "dns", + "certificates", + "headers", + "seo", +] as const; + +export type Section = (typeof SECTION_IDS)[number]; export type SectionAccent = "blue" | "purple" | "green" | "orange" | "pink" | "cyan"; diff --git a/apps/web/lib/dashboard-utils.ts b/apps/web/lib/dashboard-utils.ts index 44db4a1a..44bdf433 100644 --- a/apps/web/lib/dashboard-utils.ts +++ b/apps/web/lib/dashboard-utils.ts @@ -1,7 +1,7 @@ import type { SortingState } from "@tanstack/react-table"; import { EXPIRING_CRITICAL_DAYS, EXPIRING_SOON_DAYS } from "@domainstack/constants"; -import type { TrackedDomainWithDetails } from "@domainstack/types"; +import type { ProviderCategory, TrackedDomainWithDetails } from "@domainstack/types"; import { calculateDaysRemaining } from "@domainstack/utils/expiry"; // --------------------------------------------------------------------------- @@ -14,10 +14,7 @@ export interface AvailableProvider { domain: string | null; } -export type AvailableProvidersByCategory = Record< - "registrar" | "dns" | "hosting" | "email" | "ca", - AvailableProvider[] ->; +export type AvailableProvidersByCategory = Record; /** Filter types for domain verification status */ export type StatusFilter = "verified" | "pending"; diff --git a/apps/web/lib/trpc/errors.test.ts b/apps/web/lib/trpc/errors.test.ts new file mode 100644 index 00000000..fb402624 --- /dev/null +++ b/apps/web/lib/trpc/errors.test.ts @@ -0,0 +1,56 @@ +/* @vitest-environment node */ +import { describe, expect, it } from "vitest"; + +import { getTrpcErrorCode, isExpectedTrpcError } from "./errors"; + +describe("getTrpcErrorCode", () => { + it("returns a direct tRPC code", () => { + expect(getTrpcErrorCode({ code: "BAD_REQUEST" })).toBe("BAD_REQUEST"); + }); + + it("returns a nested data.code tRPC code", () => { + expect(getTrpcErrorCode({ data: { code: "TOO_MANY_REQUESTS" } })).toBe("TOO_MANY_REQUESTS"); + }); + + it("prefers the direct code over a nested one", () => { + expect(getTrpcErrorCode({ code: "CONFLICT", data: { code: "NOT_FOUND" } })).toBe("CONFLICT"); + }); + + it("returns undefined for non-object inputs", () => { + expect(getTrpcErrorCode("BAD_REQUEST")).toBeUndefined(); + expect(getTrpcErrorCode(null)).toBeUndefined(); + }); + + it("ignores generic string codes that are not tRPC codes", () => { + expect(getTrpcErrorCode({ code: "ENOTFOUND" })).toBeUndefined(); + expect(getTrpcErrorCode({ code: "ECONNREFUSED" })).toBeUndefined(); + }); +}); + +describe("isExpectedTrpcError", () => { + it("treats validation and rate-limit failures as expected", () => { + expect(isExpectedTrpcError({ code: "BAD_REQUEST" })).toBe(true); + expect(isExpectedTrpcError({ data: { code: "TOO_MANY_REQUESTS" } })).toBe(true); + expect(isExpectedTrpcError({ code: "NOT_FOUND" })).toBe(true); + }); + + it("treats transient server failures as unexpected so callers retry them", () => { + // The chat tools and the query client disagreed about these: one reported + // them to the user as final while the other retried. They retry now. + for (const code of [ + "INTERNAL_SERVER_ERROR", + "NOT_IMPLEMENTED", + "BAD_GATEWAY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "TIMEOUT", + ]) { + expect(isExpectedTrpcError({ code })).toBe(false); + } + }); + + it("treats errors with no tRPC code as unexpected", () => { + expect(isExpectedTrpcError(new Error("boom"))).toBe(false); + expect(isExpectedTrpcError({ code: "ECONNREFUSED" })).toBe(false); + }); +}); diff --git a/apps/web/lib/trpc/errors.ts b/apps/web/lib/trpc/errors.ts new file mode 100644 index 00000000..c3df771c --- /dev/null +++ b/apps/web/lib/trpc/errors.ts @@ -0,0 +1,95 @@ +/** + * Shared classification of tRPC error codes. + * + * Both the TanStack Query client and the chat tool runner have to answer the + * same question about a failed procedure call: did the caller cause this, or is + * it a transient server-side failure worth retrying? They used to answer it + * with separate lists that disagreed about the gateway and timeout codes, so + * one retried them and the other reported them to the user as final. + */ + +/** + * Codes that mean "try again": the request itself was fine, the server or a + * dependency was momentarily unavailable. Everything else in + * {@link TRPC_ERROR_CODES} is treated as caller-caused. + * + * Listing the retryable side rather than the expected side means a code added + * to tRPC later defaults to caller-caused, which fails safe: we surface it + * instead of hammering an endpoint that will keep rejecting us. + */ +const RETRYABLE_TRPC_ERROR_CODES = new Set([ + "INTERNAL_SERVER_ERROR", + "NOT_IMPLEMENTED", + "BAD_GATEWAY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "TIMEOUT", +]); + +/** Every code tRPC can put on an error, used to validate an unknown value. */ +export const TRPC_ERROR_CODES = new Set([ + "PARSE_ERROR", + "BAD_REQUEST", + "INTERNAL_SERVER_ERROR", + "NOT_IMPLEMENTED", + "BAD_GATEWAY", + "SERVICE_UNAVAILABLE", + "GATEWAY_TIMEOUT", + "UNAUTHORIZED", + "PAYMENT_REQUIRED", + "FORBIDDEN", + "NOT_FOUND", + "METHOD_NOT_SUPPORTED", + "TIMEOUT", + "CONFLICT", + "PRECONDITION_FAILED", + "PAYLOAD_TOO_LARGE", + "UNSUPPORTED_MEDIA_TYPE", + "UNPROCESSABLE_CONTENT", + "PRECONDITION_REQUIRED", + "TOO_MANY_REQUESTS", + "CLIENT_CLOSED_REQUEST", +]); + +/** + * Read the tRPC code off an unknown error. + * + * A `TRPCError` thrown on the server carries `code` directly; a + * `TRPCClientError` carries the server's code under `data`. Both shapes are + * checked, and the value is validated against {@link TRPC_ERROR_CODES} so an + * unrelated `code` property, such as a Node `ECONNREFUSED`, is not mistaken + * for one. + */ +export function getTrpcErrorCode(err: unknown): string | undefined { + if (typeof err !== "object" || err === null) { + return undefined; + } + + if ("code" in err) { + const code = asTrpcErrorCode(err.code); + if (code) { + return code; + } + } + + if ("data" in err && typeof err.data === "object" && err.data !== null && "code" in err.data) { + return asTrpcErrorCode(err.data.code); + } + + return undefined; +} + +/** + * True when the server raised this deliberately in response to the request. + * + * Such an error will not fix itself on retry, and its message describes + * something the caller can act on, so it is safe to show and safe to hydrate. + */ +export function isExpectedTrpcError(err: unknown): boolean { + const code = getTrpcErrorCode(err); + return code !== undefined && !RETRYABLE_TRPC_ERROR_CODES.has(code); +} + +function asTrpcErrorCode(code: unknown): string | undefined { + return typeof code === "string" && TRPC_ERROR_CODES.has(code) ? code : undefined; +} diff --git a/apps/web/package.json b/apps/web/package.json index e59ad9c5..2594c1f7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -60,7 +60,6 @@ "@vercel/functions": "^3.9.6", "@vercel/otel": "^2.1.3", "ai": "^7.0.95", - "date-fns": "^4.4.0", "geist": "^1.7.2", "jotai": "^3.0.0", "lru-cache": "^11.5.2", diff --git a/apps/web/trpc/query-client.ts b/apps/web/trpc/query-client.ts index ab71d0ef..c396bbde 100644 --- a/apps/web/trpc/query-client.ts +++ b/apps/web/trpc/query-client.ts @@ -1,61 +1,14 @@ import { defaultShouldDehydrateQuery, QueryClient } from "@tanstack/react-query"; import superjson from "superjson"; -/** - * Codes tRPC raises deliberately, where the message describes a client-side - * problem and is safe to show. Anything else (INTERNAL_SERVER_ERROR, a thrown - * driver error) may carry server internals, so it is neither retried nor - * serialized into the hydration payload. - */ -const EXPECTED_TRPC_ERROR_CODES = new Set([ - "BAD_REQUEST", - "UNAUTHORIZED", - "FORBIDDEN", - "NOT_FOUND", - "METHOD_NOT_SUPPORTED", - "CONFLICT", - "PRECONDITION_FAILED", - "PAYLOAD_TOO_LARGE", - "UNPROCESSABLE_CONTENT", - "TOO_MANY_REQUESTS", - "CLIENT_CLOSED_REQUEST", - "PARSE_ERROR", -]); +import { isExpectedTrpcError } from "@/lib/trpc/errors"; -function getTrpcErrorCode(error: unknown): string | undefined { - if (typeof error !== "object" || error === null) { - return undefined; - } - - if ( - "data" in error && - typeof error.data === "object" && - error.data !== null && - "code" in error.data - ) { - const { code } = error.data; - if (typeof code === "string") { - return code; - } - } - - if ("code" in error && typeof error.code === "string") { - return error.code; - } - - return undefined; -} - -/** Retry transient failures; skip 4xx tRPC codes and stop after two attempts. */ +/** Retry transient failures; skip deliberate tRPC errors and stop after two attempts. */ function shouldRetryQuery(failureCount: number, error: unknown): boolean { if (failureCount >= 2) { return false; } - const code = getTrpcErrorCode(error); - if (code && EXPECTED_TRPC_ERROR_CODES.has(code)) { - return false; - } - return true; + return !isExpectedTrpcError(error); } export const makeQueryClient = () => { @@ -76,10 +29,7 @@ export const makeQueryClient = () => { defaultShouldDehydrateQuery(query) || query.state.status === "pending", // Hydrate the message only for errors the server raised on purpose. // Unexpected failures are redacted so their text stays off the wire. - shouldRedactErrors: (error) => { - const code = getTrpcErrorCode(error); - return !code || !EXPECTED_TRPC_ERROR_CODES.has(code); - }, + shouldRedactErrors: (error) => !isExpectedTrpcError(error), }, }, }); diff --git a/apps/web/workflows/certificate-expiry/workflow.ts b/apps/web/workflows/certificate-expiry/workflow.ts index bccba1f4..89516aae 100644 --- a/apps/web/workflows/certificate-expiry/workflow.ts +++ b/apps/web/workflows/certificate-expiry/workflow.ts @@ -166,7 +166,7 @@ async function createNotificationRecord(params: { }): Promise<{ notificationId: string; title: string; subject: string }> { "use step"; - const { format } = await import("date-fns"); + const { formatDateLong } = await import("@domainstack/utils/date"); const { createNotification } = await import("@domainstack/db/queries/notifications"); const { @@ -183,7 +183,7 @@ async function createNotificationRecord(params: { const title = `SSL certificate for ${domainName} expires in ${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; const subject = `${daysRemaining <= 3 ? "🔒⚠️ " : "🔒 "}${title}`; - const message = `The SSL certificate for ${domainName} (issued by ${issuer}) will expire on ${format(validTo, "MMMM d, yyyy")}.`; + const message = `The SSL certificate for ${domainName} (issued by ${issuer}) will expire on ${formatDateLong(validTo)}.`; const channels: NotificationChannel[] = []; if (shouldSendEmail) channels.push("email"); @@ -219,7 +219,7 @@ async function sendCertificateExpiryEmail(params: { }): Promise<{ emailId: string }> { "use step"; - const { format } = await import("date-fns"); + const { formatDateLong } = await import("@domainstack/utils/date"); const { default: CertificateExpiryEmail } = await import("@domainstack/email/templates/certificate-expiry"); const { sendEmail } = await import("@/workflows/shared/send-email"); @@ -234,7 +234,7 @@ async function sendCertificateExpiryEmail(params: { react: CertificateExpiryEmail({ userName: userName.split(" ")[0] || "there", domainName, - expirationDate: format(validTo, "MMMM d, yyyy"), + expirationDate: formatDateLong(validTo), daysRemaining, issuer, baseUrl, diff --git a/apps/web/workflows/chat/tools.ts b/apps/web/workflows/chat/tools.ts index 50c74f4e..228ab848 100644 --- a/apps/web/workflows/chat/tools.ts +++ b/apps/web/workflows/chat/tools.ts @@ -15,12 +15,11 @@ import { DOMAIN_TOOL_DEFS, domainToolInputSchema, getDomainToolErrorMessage, - getTrpcErrorCode, - isExpectedDomainToolError, type DomainToolInput, type DomainToolProcedure, type DomainToolResult, } from "@/lib/chat/domain-tools"; +import { getTrpcErrorCode, isExpectedTrpcError } from "@/lib/trpc/errors"; export interface ToolContext { ip: string | null; @@ -54,7 +53,7 @@ async function domainLookupStep(procedure: DomainToolProcedure, domain: string, // Domain lookups return `{ success: false }` instead of throwing. // Throws here are tRPC validation/rate-limit errors, or unexpected bugs. const trpcCode = getTrpcErrorCode(err); - if (isExpectedDomainToolError(err)) { + if (isExpectedTrpcError(err)) { logger.warn({ err, domain, procedure, code: trpcCode }, "tool step failed (expected)"); return { error: getDomainToolErrorMessage(err) }; } diff --git a/apps/web/workflows/domain-expiry/workflow.ts b/apps/web/workflows/domain-expiry/workflow.ts index d2b426ee..2b8b60a2 100644 --- a/apps/web/workflows/domain-expiry/workflow.ts +++ b/apps/web/workflows/domain-expiry/workflow.ts @@ -166,7 +166,7 @@ async function createNotificationRecord(params: { }): Promise<{ notificationId: string; title: string; subject: string }> { "use step"; - const { format } = await import("date-fns"); + const { formatDateLong } = await import("@domainstack/utils/date"); const { createNotification } = await import("@domainstack/db/queries/notifications"); const { @@ -183,7 +183,7 @@ async function createNotificationRecord(params: { const title = `${domainName} expires in ${daysRemaining} day${daysRemaining === 1 ? "" : "s"}`; const subject = `${daysRemaining <= 7 ? "⚠️ " : ""}${title}`; - const message = `Your domain ${domainName} will expire on ${format(expirationDate, "MMMM d, yyyy")}${registrar ? ` (registered with ${registrar})` : ""}.`; + const message = `Your domain ${domainName} will expire on ${formatDateLong(expirationDate)}${registrar ? ` (registered with ${registrar})` : ""}.`; const channels: NotificationChannel[] = []; if (shouldSendEmail) channels.push("email"); @@ -219,7 +219,7 @@ async function sendDomainExpiryEmail(params: { }): Promise<{ emailId: string }> { "use step"; - const { format } = await import("date-fns"); + const { formatDateLong } = await import("@domainstack/utils/date"); const { default: DomainExpiryEmail } = await import("@domainstack/email/templates/domain-expiry"); const { sendEmail } = await import("@/workflows/shared/send-email"); @@ -234,7 +234,7 @@ async function sendDomainExpiryEmail(params: { react: DomainExpiryEmail({ userName: userName.split(" ")[0] || "there", domainName, - expirationDate: format(expirationDate, "MMMM d, yyyy"), + expirationDate: formatDateLong(expirationDate), daysRemaining, registrar, baseUrl, diff --git a/apps/web/workflows/reverify-ownership/workflow.ts b/apps/web/workflows/reverify-ownership/workflow.ts index c1a130b7..f1d17c62 100644 --- a/apps/web/workflows/reverify-ownership/workflow.ts +++ b/apps/web/workflows/reverify-ownership/workflow.ts @@ -130,7 +130,7 @@ interface FailureActionResult { async function determineFailureAction(domain: DomainForFailureCheck): Promise { "use step"; - const { differenceInDays: diffInDays } = await import("date-fns"); + const { calculateDaysElapsed } = await import("@domainstack/utils/expiry"); const { VERIFICATION_GRACE_PERIOD_DAYS } = await import("@domainstack/constants"); const { markVerificationFailing, revokeVerification } = await import("@domainstack/db/queries/tracked-domains"); @@ -160,7 +160,7 @@ async function determineFailureAction(domain: DomainForFailureCheck): Promise= VERIFICATION_GRACE_PERIOD_DAYS) { // Grace period exceeded - revoke verification diff --git a/apps/web/workflows/shared/notifications.ts b/apps/web/workflows/shared/notifications.ts index 37c0bf00..5f31ef1d 100644 --- a/apps/web/workflows/shared/notifications.ts +++ b/apps/web/workflows/shared/notifications.ts @@ -90,12 +90,9 @@ export async function resolveProviderNamesStep( export async function calculateDaysRemainingStep(expirationDate: Date | string): Promise { "use step"; - const { differenceInDays } = await import("date-fns"); + const { calculateDaysRemaining } = await import("@domainstack/utils/expiry"); - const now = new Date(); - const expDate = typeof expirationDate === "string" ? new Date(expirationDate) : expirationDate; - - return differenceInDays(expDate, now); + return calculateDaysRemaining(expirationDate); } /** diff --git a/apps/web/workflows/subscription-expiry/workflow.ts b/apps/web/workflows/subscription-expiry/workflow.ts index 55f0ddc8..66e3e7db 100644 --- a/apps/web/workflows/subscription-expiry/workflow.ts +++ b/apps/web/workflows/subscription-expiry/workflow.ts @@ -127,11 +127,10 @@ async function fetchUserSubscription(userId: string): Promise { "use step"; - const { differenceInDays } = await import("date-fns"); + const { calculateDaysRemaining: daysUntil } = await import("@domainstack/utils/expiry"); // Getting current time inside a step ensures deterministic replay - const now = new Date(); - return differenceInDays(endsAt, now); + return daysUntil(endsAt); } async function updateExpiryTracking(userId: string, threshold: number): Promise { @@ -162,7 +161,7 @@ async function sendSubscriptionExpiryNotification(params: { }): Promise { "use step"; - const { format } = await import("date-fns"); + const { formatDateLong } = await import("@domainstack/utils/date"); const { default: SubscriptionCancelingEmail } = await import("@domainstack/email/templates/subscription-canceling"); const { sendEmail } = await import("@/workflows/shared/send-email"); @@ -170,7 +169,7 @@ async function sendSubscriptionExpiryNotification(params: { const { userName, userEmail, endsAt, daysRemaining, threshold: _ } = params; const firstName = getFirstName(userName); - const endDate = format(endsAt, "MMMM d, yyyy"); + const endDate = formatDateLong(endsAt); const baseUrl = process.env.NEXT_PUBLIC_BASE_URL as string; // Determine urgency for subject line diff --git a/packages/polar/package.json b/packages/polar/package.json index 1505e201..4427341e 100644 --- a/packages/polar/package.json +++ b/packages/polar/package.json @@ -24,10 +24,10 @@ "@domainstack/db": "workspace:*", "@domainstack/email": "workspace:*", "@domainstack/logger": "workspace:*", + "@domainstack/utils": "workspace:*", "@polar-sh/better-auth": "^1.8.4", "@polar-sh/sdk": "^0.47.1", - "@vercel/functions": "^3.9.6", - "date-fns": "^4.4.0" + "@vercel/functions": "^3.9.6" }, "devDependencies": { "@domainstack/typescript-config": "workspace:*", diff --git a/packages/polar/src/emails.ts b/packages/polar/src/emails.ts index 9465197b..0db20530 100644 --- a/packages/polar/src/emails.ts +++ b/packages/polar/src/emails.ts @@ -1,11 +1,10 @@ -import { format } from "date-fns"; - import { getUserById } from "@domainstack/db/queries/users"; import { sendEmail } from "@domainstack/email"; import ProUpgradeSuccessEmail from "@domainstack/email/templates/pro-upgrade-success"; import SubscriptionCancelingEmail from "@domainstack/email/templates/subscription-canceling"; import SubscriptionExpiredEmail from "@domainstack/email/templates/subscription-expired"; import { createLogger } from "@domainstack/logger"; +import { formatDateLong } from "@domainstack/utils/date"; const logger = createLogger({ source: "polar/emails" }); @@ -53,7 +52,7 @@ export async function sendSubscriptionCancelingEmail( subject: "Your Pro subscription is ending", react: SubscriptionCancelingEmail({ userName: user.name || "there", - endDate: format(periodEnd, "MMMM d, yyyy"), + endDate: formatDateLong(periodEnd), baseUrl, }), }, diff --git a/packages/safe-fetch/src/dns.test.ts b/packages/safe-fetch/src/dns.test.ts index 6bcca8d5..bc0bd5d3 100644 --- a/packages/safe-fetch/src/dns.test.ts +++ b/packages/safe-fetch/src/dns.test.ts @@ -56,6 +56,12 @@ describe("isExpectedDnsError", () => { expect(isExpectedDnsError(err)).toBe(false); }); + it("does not treat a plain DNS timeout message as permanent", () => { + // The message mentions DNS but describes a transient failure, so a + // substring match on "dns" would wrongly mark it permanent. + expect(isExpectedDnsError(new Error("DNS query timed out"))).toBe(false); + }); + it("detects getaddrinfo errors by message", () => { expect(isExpectedDnsError(new Error("getaddrinfo ENOTFOUND example.com"))).toBe(true); }); diff --git a/packages/server/src/services/favicon.ts b/packages/server/src/services/favicon.ts index 566df427..fc8e28e4 100644 --- a/packages/server/src/services/favicon.ts +++ b/packages/server/src/services/favicon.ts @@ -8,15 +8,11 @@ import { ensureDomainRecord } from "@domainstack/db/queries/domains"; import { upsertFavicon } from "@domainstack/db/queries/favicons"; import { optimizeImage, storeImage } from "@domainstack/image"; -import { - isExpectedDnsError, - safeFetch, - SafeFetchError, - type SafeFetchErrorCode, -} from "@domainstack/safe-fetch"; +import { safeFetch } from "@domainstack/safe-fetch"; import type { FaviconResponse } from "@domainstack/types"; import { ttlForFavicon } from "../ttl"; +import { isDefinitiveNotFoundError } from "./fetch-errors"; // ============================================================================ // Types @@ -167,23 +163,6 @@ async function fetchIconFromSources(domain: string): Promise { return { success: false, allNotFound }; } -const DEFINITIVE_CODES = new Set([ - "host_blocked", - "host_not_allowed", - "private_ip", - "protocol_not_allowed", - "invalid_url", -]); - -function isDefinitiveNotFoundError(err: unknown): boolean { - if (!(err instanceof SafeFetchError)) return false; - - // safe-fetch also raises dns_error for lookup timeouts, which are transient. - if (err.code === "dns_error") return isExpectedDnsError(err); - - return DEFINITIVE_CODES.has(err.code); -} - // ============================================================================ // Internal: Process and Store // ============================================================================ diff --git a/packages/server/src/services/fetch-errors.ts b/packages/server/src/services/fetch-errors.ts new file mode 100644 index 00000000..454b9805 --- /dev/null +++ b/packages/server/src/services/fetch-errors.ts @@ -0,0 +1,33 @@ +import { + isExpectedDnsError, + SafeFetchError, + type SafeFetchErrorCode, +} from "@domainstack/safe-fetch"; + +/** + * Failures that mean "this URL will never serve us an asset", as opposed to + * "the attempt failed this time". Retrying any of these produces the same + * result, so callers record a permanent not-found instead of scheduling work. + */ +const DEFINITIVE_CODES = new Set([ + "host_blocked", + "host_not_allowed", + "private_ip", + "protocol_not_allowed", + "invalid_url", +]); + +/** + * True when an asset fetch failed for a reason that will not change on retry. + * + * Shared by the favicon and provider-logo services, which make the same + * permanent-versus-transient call and previously each kept their own copy. + */ +export function isDefinitiveNotFoundError(err: unknown): boolean { + if (!(err instanceof SafeFetchError)) return false; + + // safe-fetch also raises dns_error for lookup timeouts, which are transient. + if (err.code === "dns_error") return isExpectedDnsError(err); + + return DEFINITIVE_CODES.has(err.code); +} diff --git a/packages/server/src/services/provider-logo.ts b/packages/server/src/services/provider-logo.ts index 3202e159..23c743fe 100644 --- a/packages/server/src/services/provider-logo.ts +++ b/packages/server/src/services/provider-logo.ts @@ -6,15 +6,11 @@ import { upsertProviderLogo } from "@domainstack/db/queries/provider-logos"; import { optimizeImage, storeImage } from "@domainstack/image"; -import { - isExpectedDnsError, - safeFetch, - SafeFetchError, - type SafeFetchErrorCode, -} from "@domainstack/safe-fetch"; +import { safeFetch } from "@domainstack/safe-fetch"; import type { ProviderLogoResponse } from "@domainstack/types"; import { ttlForProviderIcon } from "../ttl"; +import { isDefinitiveNotFoundError } from "./fetch-errors"; // ============================================================================ // Types @@ -185,23 +181,6 @@ async function fetchIconFromSources(domain: string): Promise { return { success: false, allNotFound }; } -const DEFINITIVE_CODES = new Set([ - "host_blocked", - "host_not_allowed", - "private_ip", - "protocol_not_allowed", - "invalid_url", -]); - -function isDefinitiveNotFoundError(err: unknown): boolean { - if (!(err instanceof SafeFetchError)) return false; - - // safe-fetch also raises dns_error for lookup timeouts, which are transient. - if (err.code === "dns_error") return isExpectedDnsError(err); - - return DEFINITIVE_CODES.has(err.code); -} - // ============================================================================ // Internal: Process and Store // ============================================================================ diff --git a/packages/server/src/tls/fetch.ts b/packages/server/src/tls/fetch.ts index 9feaba80..9c6804a9 100644 --- a/packages/server/src/tls/fetch.ts +++ b/packages/server/src/tls/fetch.ts @@ -12,7 +12,7 @@ import type { TLSSocket } from "node:tls"; import { createPinnedLookup, - isExpectedDnsError as isSafeFetchDnsError, + isExpectedDnsError, resolvePublicHost, SafeFetchError, } from "@domainstack/safe-fetch"; @@ -21,7 +21,6 @@ import type { TlsFetchOptions, TlsFetchResult } from "./types"; import { InvalidCertificateDateError, isEmptyPeerCertificate, - isExpectedDnsError, isExpectedTlsError, readTlsAuthorization, walkCertificateChain, @@ -111,7 +110,7 @@ export async function fetchCertificateChain( return { success: false, error: "timeout" }; } - if (isExpectedDnsError(err) || isSafeFetchDnsError(err)) { + if (isExpectedDnsError(err)) { return { success: false, error: "dns_error" }; } @@ -164,7 +163,7 @@ function mapResolutionError(err: unknown): TlsFetchResult { } } - if (isExpectedDnsError(err) || isSafeFetchDnsError(err)) { + if (isExpectedDnsError(err)) { return { success: false, error: "dns_error" }; } diff --git a/packages/server/src/tls/utils.test.ts b/packages/server/src/tls/utils.test.ts index d81bd7a1..d60c21f7 100644 --- a/packages/server/src/tls/utils.test.ts +++ b/packages/server/src/tls/utils.test.ts @@ -14,7 +14,6 @@ import { } from "./fixtures"; import { isEmptyPeerCertificate, - isExpectedDnsError, isExpectedTlsError, parseAltNames, parseCertificateDate, @@ -104,36 +103,6 @@ describe("isExpectedTlsError", () => { }); }); -describe("isExpectedDnsError", () => { - it("returns false for non-Error values", () => { - expect(isExpectedDnsError("error")).toBe(false); - expect(isExpectedDnsError(null)).toBe(false); - }); - - it("detects ENOTFOUND errors", () => { - const err = new Error("DNS error"); - (err as unknown as { code: string }).code = "ENOTFOUND"; - expect(isExpectedDnsError(err)).toBe(true); - }); - - it("treats EAI_AGAIN as retryable, not permanent", () => { - const err = new Error("DNS error"); - (err as unknown as { code: string }).code = "EAI_AGAIN"; - expect(isExpectedDnsError(err)).toBe(false); - }); - - it("detects ENODATA when A/AAAA records are missing", () => { - const err = new Error("queryA ENODATA example.com"); - (err as unknown as { code: string }).code = "ENODATA"; - expect(isExpectedDnsError(err)).toBe(true); - }); - - it("detects getaddrinfo errors by message", () => { - const err = new Error("getaddrinfo ENOTFOUND example.com"); - expect(isExpectedDnsError(err)).toBe(true); - }); -}); - describe("parseCertificateDate", () => { it("returns null for invalid dates", () => { expect(parseCertificateDate("not-a-date")).toBeNull(); diff --git a/packages/server/src/tls/utils.ts b/packages/server/src/tls/utils.ts index 4b9e7e58..2544d0d3 100644 --- a/packages/server/src/tls/utils.ts +++ b/packages/server/src/tls/utils.ts @@ -74,35 +74,6 @@ export function isExpectedTlsError(err: unknown): boolean { ); } -/** - * Check if an error is a permanent DNS failure. - * - * `EAI_AGAIN` is excluded: getaddrinfo returns it for a *temporary* resolver - * failure, so it stays retryable. - */ -export function isExpectedDnsError(err: unknown): boolean { - if (!(err instanceof Error)) return false; - const anyErr = err as unknown as { - cause?: { code?: string; message?: string }; - code?: string; - message?: string; - }; - const code = anyErr?.cause?.code || anyErr?.code; - const message = (anyErr?.cause?.message || anyErr?.message || "").toLowerCase(); - - if (code === "EAI_AGAIN" || message.includes("eai_again")) { - return false; - } - - return ( - code === "ENOTFOUND" || - code === "ENODATA" || - code === "ENOENT" || - message.includes("getaddrinfo") || - message.includes("dns") - ); -} - /** * Thrown when a peer certificate contains a date that cannot be parsed. */ diff --git a/packages/utils/src/change-detection/detection.ts b/packages/utils/src/change-detection/detection.ts index ffce52f4..011de694 100644 --- a/packages/utils/src/change-detection/detection.ts +++ b/packages/utils/src/change-detection/detection.ts @@ -26,6 +26,7 @@ import type { } from "@domainstack/types"; import { normalizeCertificateHex } from "../certificate-hex"; +import { normalizeDnsHost } from "../providers/detection"; import { statusesAreEqual } from "./status"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -57,12 +58,11 @@ export function detectRegistrationChange( // Check nameserver changes (order-independent, case-insensitive per RFC 4343). // The root label is stripped so "ns1.example.com." and "ns1.example.com" are // the same host and do not raise a spurious change notification. - const normalizeNsHost = (host: string) => host.trim().toLowerCase().replace(/\.$/, ""); const prevNsHosts = [...snapshotNameservers] - .map((ns) => normalizeNsHost(ns.host)) + .map((ns) => normalizeDnsHost(ns.host)) .sort((a, b) => a.localeCompare(b)); const currNsHosts = [...currentNameservers] - .map((ns) => normalizeNsHost(ns.host)) + .map((ns) => normalizeDnsHost(ns.host)) .sort((a, b) => a.localeCompare(b)); const nameserversChanged = prevNsHosts.length !== currNsHosts.length || diff --git a/packages/utils/src/date.test.ts b/packages/utils/src/date.test.ts index 38a18aae..b2cc2c46 100644 --- a/packages/utils/src/date.test.ts +++ b/packages/utils/src/date.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { toDateTimeAttr, toDurationAttr } from "./date"; +import { formatDateLong, formatRelativeTime, toDateTimeAttr, toDurationAttr } from "./date"; describe("toDateTimeAttr", () => { it("returns a UTC ISO 8601 string for Date, ISO string, and epoch ms", () => { @@ -28,3 +28,92 @@ describe("toDurationAttr", () => { expect(toDurationAttr(Number.POSITIVE_INFINITY)).toBeUndefined(); }); }); + +describe("formatDateLong", () => { + it("spells the month out", () => { + expect(formatDateLong("2025-10-02T14:30:05.000Z")).toBe("October 2, 2025"); + }); + + it("uses the UTC calendar day regardless of the runtime timezone", () => { + // The reason this exists: date-fns `format` renders in local time, so this + // instant reads as the previous day anywhere west of UTC. An expiry email + // must name the same day the dashboard does. + const justAfterUtcMidnight = "2026-03-01T02:00:00.000Z"; + expect(formatDateLong(justAfterUtcMidnight)).toBe("March 1, 2026"); + }); + + it("falls back to the raw string for an unparseable date", () => { + expect(formatDateLong("not-a-date")).toBe("not-a-date"); + }); +}); + +const now = new Date("2025-06-15T12:00:00.000Z"); +const SECOND = 1_000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +/** `now` shifted by `ms`, for readable cases below. */ +function at(ms: number): Date { + return new Date(now.getTime() + ms); +} + +describe("formatRelativeTime", () => { + it("adds a suffix in both directions", () => { + expect(formatRelativeTime(at(5 * DAY), now)).toBe("in 5 days"); + expect(formatRelativeTime(at(-5 * DAY), now)).toBe("5 days ago"); + }); + + it("accepts a Date, an ISO string, and epoch milliseconds alike", () => { + const target = at(5 * DAY); + expect(formatRelativeTime(target.toISOString(), now)).toBe("in 5 days"); + expect(formatRelativeTime(target.getTime(), now)).toBe("in 5 days"); + }); + + it("picks the largest unit that fits", () => { + expect(formatRelativeTime(at(30 * SECOND), now)).toBe("in 30 seconds"); + expect(formatRelativeTime(at(90 * SECOND), now)).toBe("in 2 minutes"); + expect(formatRelativeTime(at(59 * MINUTE), now)).toBe("in 59 minutes"); + expect(formatRelativeTime(at(90 * MINUTE), now)).toBe("in 2 hours"); + expect(formatRelativeTime(at(23 * HOUR), now)).toBe("in 23 hours"); + expect(formatRelativeTime(at(29 * DAY), now)).toBe("in 29 days"); + expect(formatRelativeTime(at(31 * DAY), now)).toBe("in 1 month"); + expect(formatRelativeTime(at(200 * DAY), now)).toBe("in 7 months"); + expect(formatRelativeTime(at(400 * DAY), now)).toBe("in 1 year"); + }); + + it("promotes a full twelve months to a year", () => { + expect(formatRelativeTime(at(360 * DAY), now)).toBe("in 1 year"); + expect(formatRelativeTime(at(-360 * DAY), now)).toBe("1 year ago"); + }); + + it("rounds a half unit away from zero in both directions", () => { + // Rounding the signed value would give "48 minutes ago" here, because + // Math.round breaks the -48.5 tie upward. + expect(formatRelativeTime(at(-48.5 * MINUTE), now)).toBe("49 minutes ago"); + expect(formatRelativeTime(at(48.5 * MINUTE), now)).toBe("in 49 minutes"); + }); + + it("treats an identical instant as just past", () => { + expect(formatRelativeTime(now, now)).toBe("0 seconds ago"); + }); + + it("preserves direction when fractional seconds round to zero", () => { + expect(formatRelativeTime(at(499), now)).toBe("in 0 seconds"); + expect(formatRelativeTime(at(-499), now)).toBe("0 seconds ago"); + }); + + it("counts days on the wall clock across a DST change", () => { + // US clocks go forward on 2025-03-09, so this span is 10 days on the + // calendar but one hour short of 10 * 24h in absolute time. + const before = new Date("2025-03-05T12:00:00-05:00"); + const after = new Date("2025-03-15T12:00:00-04:00"); + expect(formatRelativeTime(after, before)).toBe("in 10 days"); + expect(formatRelativeTime(before, after)).toBe("10 days ago"); + }); + + it("returns undefined rather than throwing on an invalid date", () => { + expect(formatRelativeTime("not-a-date", now)).toBeUndefined(); + expect(formatRelativeTime(now, new Date(Number.NaN))).toBeUndefined(); + }); +}); diff --git a/packages/utils/src/date.ts b/packages/utils/src/date.ts index 7d71a2bf..fd4d2617 100644 --- a/packages/utils/src/date.ts +++ b/packages/utils/src/date.ts @@ -30,6 +30,34 @@ export function formatDate(value: string | Date): string { } } +/** + * Formats a date in UTC with the month spelled out, e.g. "October 2, 2025". + * + * The long form used in notification emails. It goes through `Intl` in UTC for + * the same reason {@link formatDate} does: date-fns `format` renders in the + * runtime's local timezone, so an expiry just after midnight UTC would be + * announced as the previous day in an email while the dashboard showed the + * correct one. + * + * @param value - ISO 8601 date string or Date + * @returns Formatted date string (e.g., "October 2, 2025") + */ +export function formatDateLong(value: string | Date): string { + try { + const d = toDate(value); + if (Number.isNaN(d.getTime())) return fallbackDateLabel(value); + + return new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(d); + } catch { + return fallbackDateLabel(value); + } +} + /** * Formats a date as ISO-like datetime in UTC using native Intl.DateTimeFormat API. * @param value - ISO 8601 date string or Date @@ -88,3 +116,101 @@ export function toDurationAttr(seconds: number): string | undefined { if (!Number.isFinite(seconds) || seconds < 0) return undefined; return `PT${seconds}S`; } + +/** + * Relative timestamps, e.g. "in 5 days" or "3 months ago". + * + * Built on `Intl.RelativeTimeFormat` so the wording comes from the platform's + * own locale data rather than a bundled table. This replaced date-fns + * `formatDistanceStrict` and reproduces its output exactly; the two details + * that make it exact are called out at their branches below. + * + * `Temporal` is deliberately not used here. It has no relative formatting of + * its own, so it would not replace this, and Safari has not shipped it. + */ +/** Cached: constructing an Intl formatter is far more costly than using one. */ +const relativeFormatter = new Intl.RelativeTimeFormat("en", { numeric: "always" }); + +const MS_PER_MINUTE = 60_000; +const MINUTES_IN_HOUR = 60; +const MINUTES_IN_DAY = 1_440; +const MINUTES_IN_MONTH = 43_200; // 30 days +const MINUTES_IN_YEAR = 525_600; // 365 days + +/** + * The offset between a date's wall-clock reading and the same reading in UTC. + * Used to cancel out a DST shift so a span is counted in calendar terms. + */ +function timezoneOffsetMs(date: Date): number { + const asUtc = new Date( + Date.UTC( + date.getFullYear(), + date.getMonth(), + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds(), + date.getMilliseconds(), + ), + ); + // Years 0-99 would otherwise be read as 19xx. + asUtc.setUTCFullYear(date.getFullYear()); + return date.getTime() - asUtc.getTime(); +} + +/** + * Formats the distance between two instants as a single unit with a suffix. + * + * @param value - The instant being described + * @param now - The instant to describe it relative to + * @returns Relative label, or undefined when either date is invalid + */ +export function formatRelativeTime(value: string | Date | number, now: Date): string | undefined { + const target = toDate(value); + const elapsedMs = target.getTime() - now.getTime(); + if (Number.isNaN(elapsedMs)) return undefined; + + const minutes = elapsedMs / MS_PER_MINUTE; + + // Days and above are counted on the wall clock, so a span crossing a clock + // change is not one hour longer or shorter than the calendar says. + const calendarMinutes = + (elapsedMs - (timezoneOffsetMs(target) - timezoneOffsetMs(now))) / MS_PER_MINUTE; + + const absMinutes = Math.abs(minutes); + const absCalendarMinutes = Math.abs(calendarMinutes); + + // Round the magnitude and reapply the direction. Rounding the signed value + // instead would turn -48.5 minutes into "48 minutes ago", because + // Math.round breaks a tie upward and -48 is the larger number. + const direction = elapsedMs < 0 ? -1 : 1; + const round = (amount: number) => direction * Math.round(Math.abs(amount)) || 0; + + if (absMinutes < 1) { + // An identical instant reads as just past rather than just future. + const seconds = round(elapsedMs / 1_000); + return relativeFormatter.format(seconds === 0 && elapsedMs <= 0 ? -0 : seconds, "second"); + } + + if (absMinutes < MINUTES_IN_HOUR) { + return relativeFormatter.format(round(minutes), "minute"); + } + + if (absMinutes < MINUTES_IN_DAY) { + return relativeFormatter.format(round(minutes / MINUTES_IN_HOUR), "hour"); + } + + if (absCalendarMinutes < MINUTES_IN_MONTH) { + return relativeFormatter.format(round(calendarMinutes / MINUTES_IN_DAY), "day"); + } + + if (absCalendarMinutes < MINUTES_IN_YEAR) { + const months = round(calendarMinutes / MINUTES_IN_MONTH); + // Rounding up to a full twelve reads better as a year. + return Math.abs(months) === 12 + ? relativeFormatter.format(Math.sign(months), "year") + : relativeFormatter.format(months, "month"); + } + + return relativeFormatter.format(round(calendarMinutes / MINUTES_IN_YEAR), "year"); +} diff --git a/packages/utils/src/expiry.test.ts b/packages/utils/src/expiry.test.ts index a04a6187..f5ad0f04 100644 --- a/packages/utils/src/expiry.test.ts +++ b/packages/utils/src/expiry.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { CERTIFICATE_EXPIRY_THRESHOLDS, DOMAIN_EXPIRY_THRESHOLDS } from "@domainstack/constants"; -import { calculateDaysRemaining, getThresholdNotificationType } from "./expiry"; +import { + calculateDaysElapsed, + calculateDaysRemaining, + getThresholdNotificationType, +} from "./expiry"; describe("getThresholdNotificationType", () => { it("returns smallest matching threshold for domain expiry", () => { @@ -118,3 +122,32 @@ describe("getThresholdNotificationType with an unusable date", () => { expect(getThresholdNotificationType(days, [30, 14, 7, 1], "domain_expiry")).toBeNull(); }); }); + +describe("calculateDaysElapsed", () => { + const now = new Date("2024-06-15T12:00:00Z"); + + it("counts whole days since a past date", () => { + expect(calculateDaysElapsed(new Date("2024-06-10T12:00:00Z"), now)).toBe(5); + }); + + it("ignores a partial day rather than rounding it up", () => { + expect(calculateDaysElapsed(new Date("2024-06-10T00:00:00Z"), now)).toBe(5); + }); + + it("returns 0 for the same instant", () => { + expect(calculateDaysElapsed(now, now)).toBe(0); + }); + + it("is negative for a future date", () => { + expect(calculateDaysElapsed(new Date("2024-06-20T12:00:00Z"), now)).toBe(-5); + }); + + it("mirrors calculateDaysRemaining with the arguments swapped", () => { + const other = new Date("2024-07-01T00:00:00Z"); + expect(calculateDaysElapsed(other, now)).toBe(calculateDaysRemaining(now, other)); + }); + + it("accepts an ISO string", () => { + expect(calculateDaysElapsed("2024-06-10T12:00:00Z", now)).toBe(5); + }); +}); diff --git a/packages/utils/src/expiry.ts b/packages/utils/src/expiry.ts index 936caa9c..f84277fb 100644 --- a/packages/utils/src/expiry.ts +++ b/packages/utils/src/expiry.ts @@ -94,3 +94,19 @@ export function calculateDaysRemaining( const diffMs = expDate.getTime() - now.getTime(); return Math.floor(diffMs / (1000 * 60 * 60 * 24)); } + +/** + * Calculate the number of whole days that have passed since a given date. + * + * The mirror of {@link calculateDaysRemaining}, for grace periods and other + * "how long has this been true" counts. Both live here so a server-side + * decision and the badge describing it can never round differently. + * + * @param since - The date to count from + * @param now - The current date (defaults to new Date()) + * @returns Whole days elapsed (negative if `since` is in the future, `NaN` if + * the date cannot be parsed) + */ +export function calculateDaysElapsed(since: Date | string, now: Date = new Date()): number { + return calculateDaysRemaining(now, typeof since === "string" ? new Date(since) : since); +} diff --git a/packages/utils/src/providers/parser.ts b/packages/utils/src/providers/parser.ts index fbbecebc..bf7831fc 100644 --- a/packages/utils/src/providers/parser.ts +++ b/packages/utils/src/providers/parser.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { PROVIDER_CATEGORIES } from "@domainstack/constants"; import type { ProviderCategory } from "@domainstack/types"; import type { Rule } from "./rules"; @@ -37,58 +38,66 @@ export interface Provider extends ProviderEntry { * } * ``` */ -const ProviderCatalogSchema = z - .object({ - ca: z.array(ProviderEntrySchema).default([]), - dns: z.array(ProviderEntrySchema).default([]), - email: z.array(ProviderEntrySchema).default([]), - hosting: z.array(ProviderEntrySchema).default([]), - registrar: z.array(ProviderEntrySchema).default([]), - }) - .superRefine((catalog, ctx) => { - // Validate all regex patterns at parse time - const validateRegexInRule = ( - rule: Rule, - category: string, - providerName: string, - path: string[], - ): void => { - if ("all" in rule) { - for (let i = 0; i < rule.all.length; i++) { - validateRegexInRule(rule.all[i], category, providerName, [...path, "all", String(i)]); - } - } else if ("any" in rule) { - for (let i = 0; i < rule.any.length; i++) { - validateRegexInRule(rule.any[i], category, providerName, [...path, "any", String(i)]); - } - } else if ("not" in rule) { - validateRegexInRule(rule.not, category, providerName, [...path, "not"]); - } else if (rule.kind === "mxRegex" || rule.kind === "nsRegex") { - try { - new RegExp(rule.pattern, rule.flags); - } catch (e) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `Invalid regex pattern in ${category}.${providerName}: ${rule.pattern} - ${e instanceof Error ? e.message : "unknown error"}`, - path: [...path, "pattern"], - }); - } - } - }; +// Typed as a Record over ProviderCategory so a category added to +// PROVIDER_CATEGORIES fails to compile here until the catalog gains a key for +// it, rather than silently parsing to a catalog that is missing the section. +const catalogShape: Record< + ProviderCategory, + z.ZodDefault> +> = { + ca: z.array(ProviderEntrySchema).default([]), + dns: z.array(ProviderEntrySchema).default([]), + email: z.array(ProviderEntrySchema).default([]), + hosting: z.array(ProviderEntrySchema).default([]), + registrar: z.array(ProviderEntrySchema).default([]), +}; - const categories: ProviderCategory[] = ["ca", "dns", "email", "hosting", "registrar"]; - for (const category of categories) { - const providers = catalog[category]; - for (let index = 0; index < providers.length; index++) { - const provider = providers[index]; - validateRegexInRule(provider.rule, category, provider.name, [ - category, - String(index), - "rule", - ]); +const ProviderCatalogSchema = z.object(catalogShape).superRefine((catalog, ctx) => { + // Validate all regex patterns at parse time + const validateRegexInRule = ( + rule: Rule, + category: string, + providerName: string, + path: string[], + ): void => { + if ("all" in rule) { + for (let i = 0; i < rule.all.length; i++) { + validateRegexInRule(rule.all[i], category, providerName, [...path, "all", String(i)]); + } + } else if ("any" in rule) { + for (let i = 0; i < rule.any.length; i++) { + validateRegexInRule(rule.any[i], category, providerName, [...path, "any", String(i)]); + } + } else if ("not" in rule) { + validateRegexInRule(rule.not, category, providerName, [...path, "not"]); + } else if (rule.kind === "mxRegex" || rule.kind === "nsRegex") { + try { + new RegExp(rule.pattern, rule.flags); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid regex pattern in ${category}.${providerName}: ${rule.pattern} - ${e instanceof Error ? e.message : "unknown error"}`, + path: [...path, "pattern"], + }); } } - }); + }; + + // Iterate the shared list: a hardcoded copy annotated `ProviderCategory[]` + // rejects an invalid member but says nothing about a missing one, so a new + // category would silently skip regex validation. + for (const category of PROVIDER_CATEGORIES) { + const providers = catalog[category]; + for (let index = 0; index < providers.length; index++) { + const provider = providers[index]; + validateRegexInRule(provider.rule, category, provider.name, [ + category, + String(index), + "rule", + ]); + } + } +}); export type ProviderCatalog = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2edec88e..839a52fe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,9 +189,6 @@ importers: ai: specifier: ^7.0.95 version: 7.0.95(zod@4.5.4) - date-fns: - specifier: ^4.4.0 - version: 4.4.0 geist: specifier: ^1.7.2 version: 1.7.2(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)) @@ -582,6 +579,9 @@ importers: '@domainstack/logger': specifier: workspace:* version: link:../logger + '@domainstack/utils': + specifier: workspace:* + version: link:../utils '@polar-sh/better-auth': specifier: ^1.8.4 version: 1.8.4(@polar-sh/sdk@0.47.1)(@stripe/react-stripe-js@4.0.2(@stripe/stripe-js@7.9.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(@stripe/stripe-js@7.9.0)(better-auth@1.7.3(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.5.8)(@opentelemetry/api@1.9.1)(@types/pg@8.23.1)(@upstash/redis@1.38.4)(kysely@0.29.5)(pg@8.23.0))(next@16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(pg@8.23.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(vitest@5.0.0))(react@19.2.8)(zod@4.5.4) @@ -591,9 +591,6 @@ importers: '@vercel/functions': specifier: ^3.9.6 version: 3.9.6(@aws-sdk/credential-provider-web-identity@3.972.49)(ws@8.21.3(bufferutil@4.1.0)) - date-fns: - specifier: ^4.4.0 - version: 4.4.0 next: specifier: 16.3.4 version: 16.3.4(@opentelemetry/api@1.9.1)(@types/node@24.13.3)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
{sentAtDateTime ? ( - {now - ? formatDistanceStrict(new Date(sentAtDateTime), now, { - addSuffix: true, - }) - : "…"} + {now ? formatRelativeTime(new Date(sentAtDateTime), now) : "…"} ) : null}