@hasna/domains
Version:
Domain portfolio, registrar, marketplace, and DNS management for AI agents — CLI + MCP + HTTP API + SDK, shared account API and PostgreSQL
164 lines • 7.24 kB
TypeScript
/**
* expiry-reconcile — registry-vs-registrar expiry disagreement check.
*
* READ-ONLY. Issues only WHOIS port-43 queries and HTTP GET. Contains no
* mutation verb of any kind and never calls a renewal path.
*
* WHY THIS COMPARES REGISTRAR AGAINST REGISTRY, AND NOT AGAINST OUR OWN DB
* -----------------------------------------------------------------------
* `syncToLocalDb` in ./brandsight.ts copies the registrar's `expires` and
* `auto_renew` straight onto our row. Our `expires_at` is therefore a MIRROR of
* the registrar, so a DB-vs-registrar check is one system read twice: it agrees
* by construction and proves nothing. Only the registry can say when the name is
* actually deleted.
*
* That distinction is load-bearing rather than academic. A registrar record
* reading 2027 against a registry record reading 2026 is invisible to every
* dashboard we own, because every dashboard we own is downstream of the
* registrar. An alarm on our own `expires_at` is silent for this entire class.
*
* THREE OUTCOMES, and UNVERIFIABLE is never scored as MATCH:
* MATCH both sides published a date and they agree within TOL_DAYS
* MISMATCH both sides published a date and they disagree
* UNVERIFIABLE at least one side published nothing -> its own count
* NOT_AT_REGISTRY registry affirmatively does not know this name
*
* UNVERIFIABLE is split, because merging the split is how a false all-clear ships:
* EXPECTED registry policy publishes no expiry (.ro/.de), or the registrar
* row is CANCELLED with no expiry. A known, stable constant.
* UNEXPECTED transport failure, throttling, unparseable date, a missing expiry
* on a TLD that normally publishes one, or a registrar we cannot
* enumerate. THIS IS AN ALARM: coverage silently shrank, and a
* shrinking denominator is how "0 mismatches" gets published by a
* blind instrument.
*/
export declare const TOL_DAYS = 1;
export declare const STALE_SOON_DAYS = 45;
/**
* TLDs whose registry publishes NO expiry field as a matter of policy.
* Membership is a measured fact. A TLD in this set producing no expiry is
* EXPECTED; one NOT in it is a coverage regression.
*/
export declare const NO_EXPIRY_TLDS: Set<string>;
/**
* Registrars we cannot enumerate an expiry from. A domain here is UNCHECKABLE
* and must never be scored as compliant.
*
* Cloudflare is declared `type: "dns"` in ./registrar.ts with no
* `createRegistrar`, and ./cloudflare.ts iterates zones while hardcoding
* `auto_renew: false`. So Cloudflare-registrar domains carry no usable expiry
* and are invisible to registrar enumeration. Reporting them as MATCH — or
* omitting them — is the failure this constant exists to prevent.
*/
export declare const UNENUMERABLE_REGISTRARS: Set<string>;
export type Subclass = "OK" | "TRANSPORT" | "THROTTLED" | "NOT_FOUND" | "NO_EXPIRY" | "BAD_DATE" | "NOT_MINE";
export type Verdict = "MATCH" | "MISMATCH" | "NOT_AT_REGISTRY" | "UNVERIFIABLE_EXPECTED" | "UNVERIFIABLE_UNEXPECTED";
/** Return an ISO yyyy-mm-dd date string, or null. null is a real answer and must never become today(). */
export declare function parseDate(s: string | null | undefined): string | null;
/** Whole days between two yyyy-mm-dd strings (a - b). */
export declare function daysBetween(a: string, b: string): number;
export interface RegistryReading {
date: string | null;
subclass: Subclass;
detail: string;
}
/**
* CLASSIFIER — pure function of (domain, method, raw body, transport error).
* Pure so it can be replayed against retained bodies with no network at all.
*/
export declare function classifyRegistry(domain: string, method: "rdap" | "whois", raw: string | null, netErr: string | null): RegistryReading;
export interface VerdictInput {
domain: string;
tld: string;
registrarDate: string | null;
registrarName?: string | null;
status?: string | null;
reading: RegistryReading;
}
export interface VerdictResult {
verdict: Verdict;
why: string;
}
/** Fold the two sides into ONE outcome. UNVERIFIABLE never becomes MATCH. */
export declare function verdictFor(input: VerdictInput): VerdictResult;
export declare const FIXTURES: {
/** NIC.MD shape: registry says 2026, registrar record says 2027. */
readonly mismatchWhois: string;
/** RDAP shape where both sides agree. */
readonly matchRdap: string;
/** A complete DENIC answer that is only 39 bytes and publishes no expiry. */
readonly denicShort: "Domain: fixture-gamma.de\nStatus: connect\n";
readonly throttle: "<html><head><title>Access denied</title></head><body>error code: 1015</body></html>";
readonly truncated: "";
/**
* Real registry shape: a GOOD answer whose footer quotes a rate-limiting
* policy. Regression fixture for a defect the original check shipped, caught
* only by running against the population — 23 of 677 real bodies look like
* this, and a marker-first classifier scores every one of them as throttled.
*/
readonly boilerplateRateLimit: string;
};
export interface PrearmCase {
name: string;
passed: boolean;
got: string;
}
/** Every case asserts a VALUE, not merely that the call returned. */
export declare function prearm(): {
ok: boolean;
cases: PrearmCase[];
};
export interface RegistrarRow {
domain: string;
expiresAt?: string | null;
status?: string | null;
registrar?: string | null;
}
export interface ReconcileDeps {
/** Resolve a TLD to a registry endpoint, or null when no route is known. */
routeFor(tld: string): {
method: "rdap" | "whois";
endpoint: string;
} | null;
/** Fetch the raw registry body. Must not throw; report transport failure via netErr. */
fetchRegistry(domain: string, method: "rdap" | "whois", endpoint: string): Promise<{
raw: string | null;
netErr: string | null;
}>;
today?(): string;
}
export interface ReconcileRecord {
domain: string;
tld: string;
method: "rdap" | "whois" | "none";
verdict: Verdict;
subclass?: Subclass;
why: string;
registrar: string | null;
registry: string | null;
status?: string | null;
daysToRegistryExpiry?: number;
}
export interface ReconcileReport {
records: ReconcileRecord[];
tally: Record<Verdict, number>;
comparable: number;
expiringSoon: ReconcileRecord[];
exitCode: number;
}
/** Deduplicate registrar rows — marker paging is inclusive and yields repeats. */
export declare function dedupeRegistrarRows(rows: RegistrarRow[]): {
rows: RegistrarRow[];
dupesRemoved: number;
};
export declare function reconcileExpiry(rows: RegistrarRow[], deps: ReconcileDeps): Promise<ReconcileReport>;
/**
* Coverage regression against a prior run: a domain that WAS comparable and is
* now unverifiable. A shrinking denominator is how "0 mismatches" gets
* published by an instrument that has gone blind.
*/
export declare function coverageRegressions(current: ReconcileRecord[], baseline: Array<{
domain: string;
verdict: Verdict;
}>): string[];
//# sourceMappingURL=expiry-reconcile.d.ts.map