setNewDomain(event.target.value)}
onKeyDown={(event) => {
diff --git a/apps/sim/lib/auth/sso/domain-verification.test.ts b/apps/sim/lib/auth/sso/domain-verification.test.ts
index e44dfcc8f3f..842a1b72609 100644
--- a/apps/sim/lib/auth/sso/domain-verification.test.ts
+++ b/apps/sim/lib/auth/sso/domain-verification.test.ts
@@ -107,13 +107,13 @@ describe('domain-verification helpers', () => {
it('verifies when the exact value is published', async () => {
mockResolveTxt.mockResolvedValue([[EXPECTED]])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})
it('joins a value split across 255-char chunks before comparing', async () => {
const midpoint = Math.floor(EXPECTED.length / 2)
mockResolveTxt.mockResolvedValue([[EXPECTED.slice(0, midpoint), EXPECTED.slice(midpoint)]])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})
it('finds the match among unrelated TXT records on the same host', async () => {
@@ -122,37 +122,41 @@ describe('domain-verification helpers', () => {
['facebook-domain-verification=abc123'],
[EXPECTED],
])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})
it('tolerates padding a DNS panel added around the value', async () => {
mockResolveTxt.mockResolvedValue([[` ${EXPECTED} `]])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(true)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('present')
})
it('rejects a near-miss value (no partial or prefix match)', async () => {
mockResolveTxt.mockResolvedValue([[`${EXPECTED}extra`], [EXPECTED.slice(0, -1)]])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})
it('rejects another org token published on the same host', async () => {
mockResolveTxt.mockResolvedValue([[buildTxtRecordValue('someone-elses-token')]])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})
- it('returns false (never throws) when the record is absent', async () => {
+ it('reports absent (never throws) when the record is not published', async () => {
mockResolveTxt.mockRejectedValue(Object.assign(new Error('no data'), { code: 'ENODATA' }))
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})
- it('returns false (never throws) when resolution fails for an infrastructure reason', async () => {
+ /**
+ * Distinct from `absent`: our resolver failed, so we learned nothing about the
+ * admin's DNS and must not tell them their record is missing.
+ */
+ it('reports unavailable when resolution fails for an infrastructure reason', async () => {
mockResolveTxt.mockRejectedValue(Object.assign(new Error('timeout'), { code: 'ETIMEOUT' }))
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('unavailable')
})
- it('returns false when the host has no TXT records at all', async () => {
+ it('reports absent when the host has no TXT records at all', async () => {
mockResolveTxt.mockResolvedValue([])
- await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe(false)
+ await expect(checkDomainTxtRecord('acme.com', TOKEN)).resolves.toBe('absent')
})
})
})
diff --git a/apps/sim/lib/auth/sso/domain-verification.ts b/apps/sim/lib/auth/sso/domain-verification.ts
index 79e8d1ef575..5e1efb63d16 100644
--- a/apps/sim/lib/auth/sso/domain-verification.ts
+++ b/apps/sim/lib/auth/sso/domain-verification.ts
@@ -98,13 +98,25 @@ export function generateVerificationToken(): string {
}
/**
- * Resolves the challenge host's TXT records against public nameservers and
- * returns true when the expected `sim-domain-verification=` value is
- * present. Never throws — resolution failures (NXDOMAIN, timeout, missing
- * record) resolve to `false` so a not-yet-propagated record simply reads as
- * unverified.
+ * Outcome of a TXT challenge lookup.
+ *
+ * `absent` and `unavailable` are kept apart because they place the fault on
+ * opposite sides: the first means the admin's record is not published yet, the
+ * second means our own resolver path failed and we learned nothing about their
+ * DNS. Collapsing both to "not found" tells an admin to fix a record that may
+ * already be correct.
*/
-export async function checkDomainTxtRecord(domain: string, token: string): Promise {
+export type DomainTxtLookup = 'present' | 'absent' | 'unavailable'
+
+/**
+ * Resolves the challenge host's TXT records against public nameservers. Never
+ * throws: a missing record resolves to `absent`, and an infrastructure failure
+ * (blocked egress, timeout, SERVFAIL) to `unavailable`.
+ */
+export async function checkDomainTxtRecord(
+ domain: string,
+ token: string
+): Promise {
const host = buildChallengeHost(domain)
const expected = buildTxtRecordValue(token)
@@ -115,24 +127,20 @@ export async function checkDomainTxtRecord(domain: string, token: string): Promi
// would otherwise fail an exact match forever with no way for the admin to
// tell why. Concatenation happens first, so trimming cannot corrupt a
// legitimate chunk boundary.
- return records.some((chunks) => chunks.join('').trim() === expected)
+ return records.some((chunks) => chunks.join('').trim() === expected) ? 'present' : 'absent'
} catch (error) {
const code = (error as NodeJS.ErrnoException)?.code
if (code && RECORD_ABSENT_DNS_CODES.has(code)) {
logger.debug('TXT verification record not published yet', { host, code })
- } else {
- // Not a missing record — our resolver path itself is failing (blocked
- // egress, timeout, SERVFAIL). Log at ERROR, not warn: the default minimum
- // level in production is ERROR, so anything below it is dropped and the
- // fault stays invisible while the admin is told their record "isn't
- // published yet". This is a genuine infrastructure fault, so ERROR is also
- // the honest severity.
- logger.error('TXT verification lookup failed for an infrastructure reason', {
- host,
- code,
- error: getErrorMessage(error),
- })
+ return 'absent'
}
- return false
+ // Our resolver path itself is failing. Log at ERROR: production's minimum
+ // level drops anything lower, so a warn would keep the fault invisible.
+ logger.error('TXT verification lookup failed for an infrastructure reason', {
+ host,
+ code,
+ error: getErrorMessage(error),
+ })
+ return 'unavailable'
}
}