mirror of
https://github.com/jakejarvis/domainstack.io.git
synced 2026-09-11 05:05:34 -04:00
refactor: remove date-fns dependency and replace with domainstack/utils
- Removed all instances of `date-fns` from the codebase, replacing its usage with utility functions from `@domainstack/utils/date` for date formatting and calculations. - Updated components and workflows to utilize the new date formatting methods, ensuring consistent date handling across the application.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 }) {
|
||||
<span suppressHydrationWarning>
|
||||
Last accessed{" "}
|
||||
<time dateTime={lastAccessedDateTime} suppressHydrationWarning>
|
||||
{now
|
||||
? formatDistanceStrict(new Date(lastAccessedDateTime), now, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "…"}
|
||||
{now ? formatRelativeTime(new Date(lastAccessedDateTime), now) : "…"}
|
||||
</time>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -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 <span>recently</span>;
|
||||
}
|
||||
|
||||
const label = now ? formatDistanceStrict(new Date(dateTime), now, { addSuffix: true }) : null;
|
||||
const label = now ? formatRelativeTime(new Date(dateTime), now) : null;
|
||||
|
||||
return (
|
||||
<time
|
||||
|
||||
@@ -5,14 +5,13 @@ import {
|
||||
IconQuestionMark,
|
||||
type TablerIcon,
|
||||
} from "@tabler/icons-react";
|
||||
import { formatDistanceStrict } from "date-fns";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
|
||||
import { useHydratedNow } from "@/hooks/use-hydrated-now";
|
||||
import { getHealthSeverity, type HealthSeverity } from "@/lib/dashboard-utils";
|
||||
import { cn } from "@domainstack/ui/utils";
|
||||
import { toDateTimeAttr } from "@domainstack/utils/date";
|
||||
import { formatRelativeTime, toDateTimeAttr } from "@domainstack/utils/date";
|
||||
|
||||
type DomainHealthBadgeProps = {
|
||||
expirationDate: Date | null;
|
||||
@@ -34,9 +33,7 @@ export function DomainHealthBadge({ expirationDate, verified, className }: Domai
|
||||
if (!dateTime || !now) return null;
|
||||
const expiration = new Date(dateTime);
|
||||
const isExpired = expiration <= now;
|
||||
const relativeTime = formatDistanceStrict(expiration, now, {
|
||||
addSuffix: true,
|
||||
});
|
||||
const relativeTime = formatRelativeTime(expiration, now);
|
||||
return `${isExpired ? "Expired" : "Expires"} ${relativeTime}`;
|
||||
}, [dateTime, now]);
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
IconRosetteDiscountCheck,
|
||||
type TablerIcon,
|
||||
} from "@tabler/icons-react";
|
||||
import { differenceInDays } from "date-fns";
|
||||
|
||||
import { BadgeWithTooltip } from "@/components/dashboard/badge-with-tooltip";
|
||||
import { useHydratedNow } from "@/hooks/use-hydrated-now";
|
||||
import { VERIFICATION_GRACE_PERIOD_DAYS } from "@domainstack/constants";
|
||||
import type { VerificationMethod, VerificationStatus } from "@domainstack/types";
|
||||
import { cn } from "@domainstack/ui/utils";
|
||||
import { calculateDaysElapsed } from "@domainstack/utils/expiry";
|
||||
|
||||
type DomainStatusBadgeProps = {
|
||||
verified: boolean;
|
||||
@@ -41,7 +41,10 @@ function getFailingTooltip(
|
||||
): string {
|
||||
const daysRemaining =
|
||||
verificationFailedAt && now
|
||||
? Math.max(0, VERIFICATION_GRACE_PERIOD_DAYS - differenceInDays(now, verificationFailedAt))
|
||||
? Math.max(
|
||||
0,
|
||||
VERIFICATION_GRACE_PERIOD_DAYS - calculateDaysElapsed(verificationFailedAt, now),
|
||||
)
|
||||
: VERIFICATION_GRACE_PERIOD_DAYS;
|
||||
|
||||
if (daysRemaining > 0) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <RelativeTimeSuffix dateTime={dateTime} text={text} className={className} />;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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
|
||||
<p className="mt-1 text-xs text-muted-foreground/75">
|
||||
{sentAtDateTime ? (
|
||||
<time dateTime={sentAtDateTime} suppressHydrationWarning>
|
||||
{now
|
||||
? formatDistanceStrict(new Date(sentAtDateTime), now, {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "…"}
|
||||
{now ? formatRelativeTime(new Date(sentAtDateTime), now) : "…"}
|
||||
</time>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<DomainToolName, string>;
|
||||
|
||||
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<typeof domainToolInputSchema>;
|
||||
|
||||
/**
|
||||
@@ -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") {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<ProviderCategory, AvailableProvider[]>;
|
||||
|
||||
/** Filter types for domain verification status */
|
||||
export type StatusFilter = "verified" | "pending";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -130,7 +130,7 @@ interface FailureActionResult {
|
||||
async function determineFailureAction(domain: DomainForFailureCheck): Promise<FailureActionResult> {
|
||||
"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<Fa
|
||||
};
|
||||
}
|
||||
|
||||
const daysFailing = diffInDays(now, failedAt);
|
||||
const daysFailing = calculateDaysElapsed(failedAt, now);
|
||||
|
||||
if (daysFailing >= VERIFICATION_GRACE_PERIOD_DAYS) {
|
||||
// Grace period exceeded - revoke verification
|
||||
|
||||
@@ -90,12 +90,9 @@ export async function resolveProviderNamesStep(
|
||||
export async function calculateDaysRemainingStep(expirationDate: Date | string): Promise<number> {
|
||||
"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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,11 +127,10 @@ async function fetchUserSubscription(userId: string): Promise<UserSubscriptionDa
|
||||
async function calculateDaysRemaining(endsAt: Date): Promise<number> {
|
||||
"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<void> {
|
||||
@@ -162,7 +161,7 @@ async function sendSubscriptionExpiryNotification(params: {
|
||||
}): Promise<boolean> {
|
||||
"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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<IconFetchResult> {
|
||||
return { success: false, allNotFound };
|
||||
}
|
||||
|
||||
const DEFINITIVE_CODES = new Set<SafeFetchErrorCode>([
|
||||
"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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<SafeFetchErrorCode>([
|
||||
"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);
|
||||
}
|
||||
@@ -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<IconFetchResult> {
|
||||
return { success: false, allNotFound };
|
||||
}
|
||||
|
||||
const DEFINITIVE_CODES = new Set<SafeFetchErrorCode>([
|
||||
"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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<z.ZodArray<typeof ProviderEntrySchema>>
|
||||
> = {
|
||||
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<typeof ProviderCatalogSchema>;
|
||||
|
||||
|
||||
Generated
+3
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user