import { type Result } from "@fjall/generator"; /** * D6 zone-hygiene classification engine (design * 2026-07-17-domain-management-gold-plating.md). Pure orchestration over * injected AWS clients — no AwsContext, no process state — so every * classification path is unit-testable with fakes. All evidence gathering is * read-only; classification NEVER mutates anything. Destructive follow-ups * (residue deletion) route through the destruction ceremony on the Phase 3 * command surface, never from here. * * Fail-closed contract (fail-closed-default-policy ADR): whenever evidence * cannot be obtained (AccessDenied, unreadable template, throttle), the * affected records resolve to the conservative bucket — `unknown`, never * `residue` — and the report carries a warning naming the gap and its cure. * * Template evidence is each live Fjall stack's Processed template body, with * ONE honest widening for non-JSON bodies (hand-written YAML templates, e.g. * the CDK bootstrap stack): a CloudFormation GetTemplateSummary listing no * AWS::Route53::* and no Custom::* resource type PROVES the stack claims no * record keys, so it indexes as contributing nothing, with no warning and * template evidence still complete. The summary is absence-only evidence - * it names resource types, never their properties - so a summary listing * either namespace, or no readable summary at all, keeps the fail-closed * warning, and a summary can never prove a positive claim (the cross-account * delegation proof still needs the literal DelegatedZoneName from a JSON * template). * * Evidence is account-local with ONE deliberate exception: callers may name * delegated child domains (ClassifyZoneOptions.delegatedChildren) plus a * child-account client factory, and the classifier then proves a child's * parent-side NS row `satellite` against the CHILD account. Proof needs all * three steps: a live child domain stack, a Processed template whose * delegation resource literally claims the row, and stack outputs whose * nameservers SET-match the live values. Any gap in that three-step proof * warns and leaves the row `unknown`. The cross-account surface is * deliberately ONLY the delegated-child NS row; arbitrary child-account * stacks earn no satellite coverage here. * * Residue evidence comes in exactly two shapes, both verified against live * state: ACM validation CNAMEs no live certificate references, and dangling * A/AAAA aliases into AWS-managed namespaces (CloudFront/ELB) whose target a * read-only DNS probe proves NXDOMAIN. Everything else stays `unknown`. */ export type RecordClassification = "declared" | "satellite" | "residue" | "unknown"; export interface ClassifiedRecord { /** Normalised FQDN: lowercase, no trailing dot, octal escapes decoded. */ readonly name: string; readonly type: string; /** * Routing-policy variant identity. Each SetIdentifier variant is its own * classified record — evidence is evaluated, and remediation consented, * per variant. */ readonly setIdentifier?: string; /** Live record values (alias records carry the alias target DNS name). */ readonly values: readonly string[]; readonly classification: RecordClassification; /** Owning CloudFormation stack, when proven (declared/satellite). */ readonly ownerStack?: string; /** Human-readable evidence note (masked at construction). */ readonly detail?: string; } export interface ZoneClassificationReport { readonly hostedZoneId: string; readonly zoneName: string; readonly records: readonly ClassifiedRecord[]; readonly counts: Readonly>; /** Evidence gaps that forced conservative verdicts (already masked). */ readonly warnings: readonly string[]; } /** * Minimal structural AWS SDK v3 client — the injectable seam. Command * instances are the real SDK command classes, so fakes can dispatch on * `instanceof`. */ export interface AwsSdkClientLike { send(command: unknown, options?: { abortSignal?: AbortSignal; }): Promise; } export interface ZoneClassifierClients { readonly route53: AwsSdkClientLike; readonly cloudFormation: AwsSdkClientLike; /** * ACM clients keyed by region. Must cover every region a relevant * certificate can live in: the zone's home region (ALB/API certs) and * us-east-1 (CloudFront certs, D3). */ readonly acmByRegion: ReadonlyMap; } /** * DNS-probe verdict for a dangling-alias target. The probe answers an * existence question, not a liveness one: `resolves` means address records * were observed on either family, so the target NAME still exists in DNS and * the record cannot be dangling. Only `nxdomain`, the resolver's name-wide * proof the name no longer exists (RFC 2308: NXDOMAIN means no records of * ANY type), can support a `residue` verdict. `indeterminate` (SERVFAIL, * timeout, cancellation, or NODATA on both families; see * createSystemDnsNameProbe for why double NODATA proves nothing) fails * closed to `unknown` per the fail-closed-default-policy ADR. */ export type DnsProbeVerdict = "nxdomain" | "resolves" | "indeterminate"; /** Injectable DNS probe seam — unit tests supply fakes, never live DNS. */ export type DnsNameProbe = (dnsName: string, abortSignal?: AbortSignal) => Promise; /** * Build the default probe over per-family address lookups. The probe answers * one question: does the target NAME still exist in DNS? c-ares surfaces * NXDOMAIN as ENOTFOUND, and NXDOMAIN is name-wide (RFC 2308: no records of * ANY type), so an A-query ENOTFOUND is definitive absence with no AAAA * query needed. ENODATA on A means NOERROR with no A records (e.g. an * IPv6-only or dualstack-without-public-IPv4 load balancer), so the AAAA * family gets a say: addresses prove the name exists (`resolves`), ENOTFOUND * is still definitive absence. A second ENODATA proves NOTHING and stays * `indeterminate`: cloudfront.net's authoritative servers answer NOERROR * with zero records for EVERY name (deleted distributions included), never * NXDOMAIN, so double NODATA is the mainline shape of a dangling CloudFront * target, not evidence of existence. Any other code in either query * (ESERVFAIL, ETIMEOUT, ECANCELLED and friends) is `indeterminate` so * dependent records fail closed. Exported as a factory so the * code-to-verdict matrix is testable without live DNS. */ export declare function createSystemDnsNameProbe(resolveAddresses?: (dnsName: string, rrtype: "A" | "AAAA", abortSignal?: AbortSignal) => Promise): DnsNameProbe; export declare const systemDnsNameProbe: DnsNameProbe; /** Progress phases, in emission order — the sync operation maps these onto steps. */ export type ZoneClassifierPhase = "read-records" | "index-stacks" | "check-acm" | "classify"; export interface ClassifyZoneOptions { readonly zone: { readonly hostedZoneId: string; readonly zoneName: string; }; /** * The domain stack that owns this zone (deploy-state convention). Records in * its CURRENT deployed template classify `declared`. Omit when the zone has * never been stack-owned (pre-adoption import flows) — only zone-owned * SOA/apex-NS classify declared then. */ readonly domainStackName?: string; readonly clients: ZoneClassifierClients; /** * Pre-built satellite index (per-run cache). Callers classifying several * zones in one account build this once via `buildSatelliteRecordIndex` and * pass it in; omitted, the classifier builds it itself. */ readonly satelliteIndex?: SatelliteRecordIndex; /** * DNS probe verifying dangling-alias targets (see DnsProbeVerdict). * Injected by tests; production callers rely on the system-resolver default. */ readonly dnsProbe?: DnsNameProbe; /** * Delegated child domains that may hold a cross-account claim on their * parent-side NS rows (see module doc). Children whose NS row is absent, * or already claimed by the domain stack or the satellite index (the * same-account delegation path), are skipped silently and cost nothing. * Omitted, the classifier behaves exactly as before: account-local * evidence only. */ readonly delegatedChildren?: readonly DelegatedChildDomain[]; /** * Factory minting read-only clients into a delegated child's account. * Required for any cross-account proof: when children need evidence and * this is absent, ONE warning names the cure and every child's NS row * stays 'unknown', fail-closed. */ readonly childAccountClientFactory?: ChildAccountClientFactory; readonly abortSignal?: AbortSignal; readonly onPhase?: (phase: ZoneClassifierPhase) => void; } export interface SatelliteRecordIndex { /** * record key (`name|TYPE|setIdentifier`, empty third segment for simple * records) → owning live Fjall stack name. */ readonly byRecordKey: ReadonlyMap; /** * Every live Fjall-owned stack that was enumerated, whether or not it * declares Route53 records. Certificate-ownership evidence (D3) resolves * validation-CNAME cert ARNs against these stacks' physical resources. */ readonly stackNames: readonly string[]; readonly warnings: readonly string[]; } /** * A delegated child domain whose parent-side NS row may be written by a * domain stack in ANOTHER AWS account. Enumerated by the caller (the CLI * reads the delegated domains out of fjall-config.json); the classifier * only proves or declines each claim, fail-closed. */ export interface DelegatedChildDomain { /** Child zone FQDN, e.g. "development.fjall.io". */ readonly zoneName: string; /** * 12-digit AWS account id the child's domain stack deploys into. Absent, * the child is skipped fail-closed with a warning naming the config cure. */ readonly accountId?: string; /** * Region the child's domain stack last deployed into. Threaded through to * the ChildAccountClientFactory so child clients are minted where the * stack lives; absent, the factory falls back to its own region * resolution. */ readonly region?: string; /** * Child domain stack name override. Defaults to the deploy-state * convention, getDomainStackName(zoneName). */ readonly stackName?: string; } /** Clients minted into a delegated child's account, read-only use only. */ export interface ChildAccountClients { readonly cloudFormation: AwsSdkClientLike; } /** * Mint clients for a delegated child's account. `region` names where the * child's stack deployed (config-carried, DelegatedChildDomain.region); * omitted, the factory chooses its own fallback. Any error, including an * interactivity requirement (an expired SSO session must never hang a * non-interactive run), resolves the Result to failure; the classifier then * warns and leaves that child's NS row 'unknown' without touching its * siblings. */ export type ChildAccountClientFactory = (accountId: string, region?: string) => Promise>; /** * Route53 returns names with a trailing dot and octal-escaped bytes * (`\052` = `*`). Normalise both directions (live names AND template names) * through this so keys always compare. * * NOT the same as {@link normaliseDnsName} (orchestration/domain/types.ts), * which only lowercases and sheds the trailing dot: this normaliser * additionally DECODES Route53's octal escapes, so `\052.example.com.` * becomes `*.example.com`. Use this one whenever the input can come from a * Route53 API response (ListResourceRecordSets, ACM validation records); * use normaliseDnsName for plain resolver/user-supplied names where a * backslash sequence must stay literal. */ export declare function normaliseRecordName(name: string): string; /** * Why a stack's template evidence could not be indexed. Discriminated so * callers dispatch on structure, never on message text: readStackRecordKeys * keys its GetTemplateSummary fallback off `non-json-template` alone, and * buildSatelliteRecordIndex appends its cloudformation:GetTemplate cure only * to untyped transport failures (every TemplateEvidenceError message already * names its own cure inline). */ export type TemplateEvidenceFailureReason = "non-json-template" | "non-literal-delegation" | "missing-template-body"; /** * Typed template-evidence failure; the message always names the cure but * never the owning stack - each warning-producing caller wraps the message * with the stack named, so naming it here too would double it. */ export declare class TemplateEvidenceError extends Error { readonly reason: TemplateEvidenceFailureReason; constructor(reason: TemplateEvidenceFailureReason, message: string); } /** * Extract every (name, type, setIdentifier) a CloudFormation template * declares via `AWS::Route53::RecordSet` or `AWS::Route53::RecordSetGroup`. * Intrinsic (non-string) names — or an intrinsic SetIdentifier, whose * variant identity is unknowable — cannot be indexed; their live records * stay in the conservative bucket rather than being guessed at. * * `Custom::CrossAccountZoneDelegation` resources (D8 child-writes) claim the * `|NS|` row they UPSERT into the parent zone, so a * same-account child stack's delegation classifies declared/satellite * exactly like a RecordSet. Unlike a RecordSet's routinely-intrinsic Name, a * delegation without a literal DelegatedZoneName is FAILURE, not a skip: * Fjall's delegated-domain path always synthesises the literal zone name, so * a non-literal one is malformed, and the claim it hides targets ANOTHER * zone's NS row, where a silent drop would leave that row 'unknown' with no * warning naming this stack as the evidence gap. * * A non-JSON body (a hand-written YAML template) is FAILURE, never an empty * key set: the stack may well declare records we cannot see, so treating it * as zero claims would let its live records mint `residue`. Structurally * valid JSON without record declarations IS genuine evidence of zero claims * and stays success(empty). Deliberately no YAML parsing here: correct * CloudFormation YAML requires the short-form intrinsic tag schema, a * dependency and correctness surface of its own, so the cure named in the * failure is conversion, not a lossy parse. Failures are typed * ({@link TemplateEvidenceError}); readStackRecordKeys dispatches on the * `non-json-template` reason to mount its absence-only GetTemplateSummary * fallback (see module doc). */ export declare function extractTemplateRecordKeys(templateBody: string): Result, Error>; /** * Enumerate live Fjall-owned stacks in the account and index every Route53 * record their CURRENT deployed templates declare. Read-only; build once per * run and share across zones (D6 "cached per run"). Enumeration or template * failures degrade to warnings — affected records classify `unknown`, never * `satellite` or `residue`. A non-JSON template proven record-free by its * resource-type summary (see readStackRecordKeys) indexes as contributing * nothing with NO warning: warnings here flip templateEvidenceComplete and * suppress dangling-alias residue probing zone-wide, so only genuine * evidence gaps may land in them. */ export declare function buildSatelliteRecordIndex(cloudFormation: AwsSdkClientLike, options?: { readonly excludeStackNames?: readonly string[]; readonly abortSignal?: AbortSignal; }): Promise; /** * Classify every live record set in a hosted zone against the account's * deployed CloudFormation state and live ACM certificates. See module doc for * the fail-closed contract. */ export declare function classifyZoneRecords(options: ClassifyZoneOptions): Promise>;