Imported from seankrux/domainpulse (
AGENTS.md). Install upstream withnpx skills add seankrux/domainpulse. Copyright stays with the author.
AGENTS.md — Invariants & Guardrails for DomainPulse
Read this before changing monitoring, status, theming, or filter code. This file exists so that different agents (Claude, Gemini, Qwen, etc.) don't re-introduce bugs that have already been fixed. If you change a behavior described here, update this file in the same PR.
1. Domain liveness — the single source of truth
Liveness is decided ONLY by the uptime check (/api/check →
safeHeadRequest). SSL, DNS, WHOIS/expiry and tech-detection are
enrichment and must NEVER change a domain's ALIVE/DOWN status.
Why this rule exists: enrichment sub-checks used to run inside Promise.all,
so a single rejected call (e.g. an expired auth token throwing Unauthorized)
turned a perfectly alive domain into Error. That produced the
"seanguillermo shows Error" and amber "200 OK" bug reports.
Contract — in services/domainService.ts (checkDomainWithSSL):
- The uptime result sets
status/statusCode/latency. - Enrichment runs via
Promise.allSettled, neverPromise.all. - A rejected enrichment promise falls back to a safe default
(
SSLStatus.Unknown,{ status: 'unknown' },undefined,confidence: 'low'). - Do not revert this to
Promise.all.
2. What counts as "up" — isReachableStatus
In api/_utils/ssrfGuard.ts:
isReachableStatus(status) === status > 0 && status < 500
A server answering with 401 / 403 / 404 / 405 / 429 is reachable and therefore ALIVE. Only 5xx or no response (status 0) is DOWN.
Reason: the uptime probe uses HEAD. Many real sites reject HEAD (amazon.com
→ 405) or block bot user-agents (→ 403). Treating only 2xx as "up" falsely
flagged those domains offline.
Also in safeHeadRequest: when HEAD returns 405 or 501, we retry once
with GET to recover the true status. Keep this fallback.
The /api/check flow lives in probeUptime(url) in ssrfGuard.ts — the
single source of truth shared by the Vercel function (api/check.ts) and
the dev proxy (server/proxy.ts). It returns a ready-to-send { httpStatus, body }. Do not hand-roll the probe/mapping in either endpoint; call
probeUptime so the two environments can never disagree. It:
- runs
safeHeadRequest(→toCheckResult, still the ALIVE/DOWN mapper); - canonicalises www↔apex: if the host doesn't resolve, it retries once with
www.toggled (toggleWww) — so "I addedexample.combut it only serveswww.example.com" (or vice-versa) resolves correctly; - maps a genuinely unresolvable domain (NXDOMAIN) to DOWN, not to a 400
/
Error. Only an SSRF/scheme reject (private address, non-http) returns HTTP 400. Theunresolvableflag onSafeHeadResult(set from thevalidateOutboundUrlResolvedfailurecode) is what distinguishes "dead domain → DOWN" from "blocked target → 400". Do not collapse these back into one branch.
Locked in by tests/unit/ssrf.test.ts ("isReachableStatus (liveness
contract)", "toCheckResult (shared liveness mapping)", "toggleWww", and
"probeUptime (canonicalisation + liveness mapping)").
3. Status badge text vs colour
In components/StatusBadge/StatusBadge.tsx: the HTTP code (e.g. "200 OK") is
shown only when status is Alive or Down. Never show the code for
Error / Unknown / Checking — otherwise a stale statusCode renders as
"200 OK" under the amber Error colour (the exact bug from the screenshots).
Status colours live in theme/statusColors.ts (STATUS_COLORS) and are the
single source of truth. Never inline status colour strings in components.
- Alive → emerald, Down → red, Checking → blue, Unknown → zinc, Error → amber.
4. Filtering & sorting (App.tsx → displayDomains)
- Filter matching keys off
status,ssl.status,groupId, and a lowercased URL substring search. This is correct — don't "simplify" it. - Sort comparators must use
??, not||, for order lookups. The order maps start at0(e.g.Valid: 0,active: 0);0 || fallbackwrongly yields the fallback. UsesslOrder[x] ?? 4andexpiryOrder[x] ?? 3.
5. Auth token — one storage, one reader
The session token is written by AuthProvider to sessionStorage (key
domainpulse_auth_session). The ONLY client-side reader is
getSessionToken() in utils/authSession.ts. Every service/hook that attaches
a Bearer token MUST go through it.
Why this rule exists: services each kept a private getStoredToken() that read
localStorage, but the token lives in sessionStorage. The read returned
null, so no Authorization header was sent, /api/check answered 401, and
the client threw Unauthorized → an otherwise ALIVE domain showed as
Error. Bulk import worked (it read sessionStorage), single add/re-check
did not — the "alive shows Error / inconsistent" report.
Contract:
- Read the token ONLY via
getSessionToken(). Never calllocalStorage.getItem('domainpulse_auth_session')or hand-roll asessionStorageparse in a service. checkSingleDomainandcheckBatchmust BOTH passauthTokeninServiceConfig(the Web Worker has nosessionStorage, so the token has to be read on the main thread and handed in).- Locked in by
tests/unit/authSession.test.tsandtests/unit/checkDomainWithSSL.test.ts.
7. Auth is opt-in — public when no password is configured
Auth is opt-in. When no password hash is configured the API allows
unauthenticated requests and AuthGuard skips the login portal (public demo).
When the hash is set, the API requires a Bearer JWT and AuthGuard shows
the lock/login gate until POST /api/login succeeds.
- Password hash env: prefer server-only
PASSWORD_HASH(hash:salt). LegacyVITE_PASSWORD_HASHstill works. AvoidVITE_so the hash is not eligible for the client bundle. verifyAuth(api/_utils/auth.ts) returnstruewhen no hash is set (public/demo mode), and only requires a valid Bearer token when a hash IS set. This mirrors the dev proxy (server/proxy.ts) — prod and dev must agree.AuthGuardasksGET /api/auth-status({ authRequired }):false→ render the dashboard;true→ showLoginPage(lock UI) until authenticated./api/auth-statusexists on both Vercel (api/auth-status.ts) and the proxy.AuthGuardfails open to public mode if the status probe errors; a protected API still 401s →auth-invalid→ re-probe → login page.JWT_SECRETis required only when auth is enabled (VITE_PASSWORD_HASHset) in production. Don't reinstate an unconditional "deny all / throw in production when unconfigured".- The session token must flow through
getSessionToken()(see §5). - Locked in by
tests/unit/auth.test.tsandtests/unit/AuthGuard.test.tsx.
6. Timeout guards liveness only — never enrichment
In checkDomainWithSSL the hard timeout (serviceConfig.timeout) wraps ONLY
the uptime probe (resolveLiveness). Once liveness is known, enrichment runs
under Promise.allSettled and a soft timeout (raceToDefault) that
resolves to safe defaults instead of rejecting. A slow OR failing
enrichment can therefore never flip an ALIVE domain to Error — Error is
reachable only when the probe itself fails (auth/network/timeout). Do not
re-wrap the whole check (liveness + enrichment) in one rejecting timeout race;
that was the loophole that let slow SSL/WHOIS calls produce false Error.
Known gaps (intentionally not fixed yet — don't "fix" silently)
- Light mode is not implemented. The app is dark-only: the shell uses
hardcoded dark classes (
bg-zinc-950,text-zinc-100) with almost nodark:variants. The header no longer shows a dark-mode toggle (it was a no-op). Building real light mode is a large, app-wide effort. Decision: leave as-is for now unless doing a full scoped theming pass.
Fix log (2026-07-05) — www↔apex canonicalisation + unresolvable → DOWN
- Symptom ("I added domains; some check the www, some non-www didn't"): a
domain typed in the form that doesn't resolve (apex when only
wwwhas an A record, or vice-versa) hitdns.lookupNXDOMAIN → SSRF guard returnedHost did not resolve→/api/checkreplied400 Blocked→ the client saw!response.ok, exhausted endpoints, and surfaced amber Error. No attempt was ever made on the other canonical form. - Fix: new shared
probeUptime()inssrfGuard.ts(used byapi/check.ts+server/proxy.ts). On a non-resolving host it retries once withwww.toggled (toggleWww); a still-unresolvable domain maps to DOWN (a real negative signal), while SSRF/scheme rejects still return400. Theunresolvablediscriminator (fromvalidateOutboundUrlResolved's failurecode) is what keeps "dead domain → DOWN" separate from "private → 400". This also closes former known-gap #2. See §2. - Tests:
tests/unit/ssrf.test.ts("toggleWww", "probeUptime …").
Fix log (2026-06-22) — login-less demo returned 401 → Error
- Primary symptom ("added domain shows Error even though alive"): the
front end's
AuthGuardis a "skip authentication" stub, so it never logs in and sends no token — butverifyAuthdenied unauthenticated requests (401), so every freshly-checked domain showed Error. Reproduced live: freshcloudflare.com→ amber Error + two401s in the console. - Fix: auth is now opt-in.
verifyAuthallows requests when noVITE_PASSWORD_HASHis configured (matches the dev proxy);JWT_SECRETis required only when auth is enabled. After the fix the same flow showscloudflare.com→ 200 OK / Operational. See §7. - Tests:
tests/unit/auth.test.ts.
Fix log (2026-06-22) — token storage + timeout
- Root cause of "alive domain shows Error when added":
AuthProviderwrites the session tosessionStorage, butdomainService/sslService/dnsService/gmbService/expiryServiceeach read it fromlocalStorage(onlytechDetectionServiceread the right store). The token came backnull→401→Unauthorizedthrown →Error.checkSingleDomainalso passed no token at all, so single add/re-check failed while bulk import (which readsessionStorage) succeeded — the "inconsistent" symptom. - Fix: new
utils/authSession.tsgetSessionToken()is the single client-side token reader (readssessionStorage). All six services +useMonitoringnow use it; the six duplicatedgetStoredToken()copies are deleted.checkSingleDomainnow passesauthTokenlikecheckBatch. - Fix:
checkDomainWithSSLrestructured — hard timeout guards only the liveness probe; enrichment usesallSettled+ a softraceToDefaulttimeout so a slow enrichment can't time-out the whole check into Error (see §6). Timers are now cleared (no danglingsetTimeout). - Tests:
tests/unit/authSession.test.ts(token source of truth) +tests/unit/checkDomainWithSSL.test.ts(liveness invariant incl. hang).
Fix log (2026-06-21/22)
ssrfGuard.ts: addedisReachableStatus(up = status < 500) +GETfallback on 405/501. Fixes false-offline for amazon.com and HEAD-rejecting sites.domainService.ts: enrichment moved toPromise.allSettledso a failed SSL/DNS/WHOIS/tech call no longer downgrades an alive domain to Error.StatusBadge.tsx: HTTP code shown only for Alive/Down (kills amber "200 OK").App.tsx: sort order lookups use??instead of||(zero-index bug).tests/unit/ssrf.test.ts: liveness contract tests added.- Refactor: extracted
CheckResult+toCheckResult()intossrfGuard.ts, removing the duplicated probe→response mapping fromapi/check.tsandserver/proxy.ts(prevents prod/dev drift). Behavior-preserving; covered by tests. - Cleanup: extracted
sslLookup.ts/dnsLookup.ts/whoisLookup.tsintoapi/_utils.api/ssl|dns|whois.tsand the dev proxy now share ONE implementation each. The dev proxy's fake "Local Simulation Registrar" WHOIS is gone — local now matches production. Removed stale review/handoff docs (superseded by this file) and a tracked vitest temp artifact.
Enrichment endpoints — one implementation each
ssl, dns, whois, gmb, tech-detect, canonical, email-auth,
security-headers each have a Vercel function
(api/*.ts) AND a dev proxy route (server/proxy.ts). The actual lookup logic
lives ONCE in api/_utils/{sslLookup,dnsLookup,whoisLookup,gmbLookup,techLookup,canonicalLookup,emailAuthLookup,securityHeadersLookup}.ts.
Both sides import it. Never reimplement a lookup inline in an endpoint or
the proxy — extend the shared util so prod and dev can't diverge. The proxy
must serve every /api/* route the frontend services call
(check, ssl, dns, whois, gmb, tech-detect, canonical, email-auth, security-headers, auth-status, login) or that feature breaks in dev.
All outbound-fetching utils (check, tech-detect, security-headers) go through the SSRF guard
(validateOutboundUrlResolved / safeHeadRequest). Keep it that way.
Cursor Cloud specific instructions
Dependencies are refreshed automatically on startup (npm ci --legacy-peer-deps);
you do not need to reinstall. Standard commands live in package.json and the
README "Available Commands" table — use those. Non-obvious caveats only:
- Node 22 is used here (
.nvmrc= 22;enginesrequires >=20). Fine as-is. - Installs use
--legacy-peer-depsto match CI. Plainnpm cicurrently works too, but CI (.github/workflows/ci.yml) and the startup update script both pass--legacy-peer-depsto stay resilient to peer-range drift; prefer it for manual installs so you match CI. - Two apps, three dev ports.
npm run dev:allruns the dashboard on:3000and the Express proxy on:3001together (viaconcurrently) — the dashboard is non-functional without the proxy, which Vite proxies/api/*to.npm run devruns the separate marketing site on:3002. Verify the dashboard against:3000, not:3001. - Runs login-less with no secrets. No
.env.localis needed: withVITE_PASSWORD_HASHunset the API allows unauthenticated requests (see §7), and missingGOOGLE_PLACES_API_KEYonly degrades GMB checks to "Unknown". Domain checks make real outbound HTTP/DNS/TLS calls, so live results depend on network egress from the VM. - Unit vs GUI tests are separate runners.
npm run test/test:unit(Vitest, jsdom) need no browser.npm run test:gui(Playwright) needs a browser first:npx playwright install chromium(not part of the update script). Playwright auto-starts its own servers viawebServer.