{"record":{"id":"9bfd93eeb5ad96ed","repo":"koala73/worldmonitor","slug":"dns-recordtype-lookup-failed-status-data-sta","errorCode":null,"errorMessage":"DNS ${recordType} lookup failed: status ${data.Status}","messagePattern":"DNS (.+?) lookup failed: status (.+?)","errorType":"exception","errorClass":"Error","httpStatus":400,"severity":"error","filePath":"api/_notification-webhook-ssrf.ts","lineNumber":198,"sourceCode":"  }\n\n  return null;\n}\n\nasync function resolveDnsJson(hostname: string, recordType: 'A' | 'AAAA'): Promise<string[]> {\n  const url = new URL(DNS_JSON_ENDPOINT);\n  url.searchParams.set('name', hostname);\n  url.searchParams.set('type', recordType);\n  const response = await fetch(url, {\n    headers: {\n      Accept: 'application/dns-json',\n      'User-Agent': 'WorldMonitor-Notification-Webhooks/1.0',\n    },\n    signal: AbortSignal.timeout(DNS_RESOLUTION_TIMEOUT_MS),\n  });\n  if (!response.ok) throw new Error(`DNS ${recordType} lookup failed: HTTP ${response.status}`);\n  const data = await response.json() as { Status?: number; Answer?: Array<{ type?: number; data?: string }> };\n  if (data.Status !== 0) throw new Error(`DNS ${recordType} lookup failed: status ${data.Status}`);\n  const expectedType = recordType === 'A' ? 1 : 28;\n  return (data.Answer ?? [])\n    .filter(answer => answer.type === expectedType && typeof answer.data === 'string')\n    .map(answer => answer.data!);\n}\n\nasync function defaultResolveHostname(hostname: string): Promise<string[]> {\n  const records = await Promise.all([\n    resolveDnsJson(hostname, 'A'),\n    resolveDnsJson(hostname, 'AAAA'),\n  ]);\n  return records.flat();\n}\n\n/**\n * Fail fast at registration when the webhook hostname currently resolves to a\n * private or reserved address. Delivery repeats this check (and pins its\n * connection) because DNS can change after registration.","sourceCodeStart":180,"sourceCodeEnd":216,"githubUrl":"https://github.com/koala73/worldmonitor/blob/eeab0a219fce0f02a00603b532dbae9041b934ac/api/_notification-webhook-ssrf.ts#L180-L216","documentation":"Thrown by the widget chat composer when POST widgetAgentUrl() returns a non-OK status. Before throwing, the modal tries to parse the body as a WidgetAgentHealth payload and calls reportWidgetEntitlementDesync() with the status, whether a tester key was used, the client's entitlement belief, and the captured user id. It is the generic client-side surface for auth (401), entitlement/tier (402/403), rate-limit (429), and server (5xx) rejections from the widget-agent edge endpoint; the localized 'widgets.serverError' template renders the numeric status into the chat transcript.","triggerScenarios":"buildWidgetAuthHeaders(isPro) produced stale or absent auth (expired Clerk session token, missing pro key) and the endpoint answered 401; the client believed it had Pro entitlement but the server disagreed (desync) returning 402/403; widget-agent upstream 5xx; tester key (X-WorldMonitor-Key) used where invalid.","commonSituations":"Session token expiring during a long chat; plan downgrade not yet reflected client-side (the desync Sentry report exists to catch exactly this); Vercel edge function misconfiguration or backend outage; account switch invalidating in-flight tokens.","solutions":["Branch on the numeric status in the message: 401 re-authenticate, 402/403 verify Pro entitlement and widget/pro keys, 429 wait and retry, 5xx check widget-agent health and logs","Inspect the Sentry desync report emitted by reportWidgetEntitlementDesync to see whether client belief and server state disagreed","Reopen the modal after re-auth so buildWidgetAuthHeaders() rebuilds headers from a fresh session","If persistent, confirm the widgetAgentUrl() target and edge deployment are correct for the environment"],"exampleFix":"// before\nconst res = await fetch(widgetAgentUrl(), { method: 'POST', headers: reqHeaders, body });\nif (!res.ok) throw new Error(t('widgets.serverError', { status: res.status }));\n\n// after: act on the status before surfacing the generic error\nconst res = await fetch(widgetAgentUrl(), { method: 'POST', headers: reqHeaders, body });\nif (!res.ok) {\n  if (res.status === 401) { await promptSignIn(); return; }\n  if (res.status === 429) { scheduleRetryAfter(res); return; }\n  throw new Error(t('widgets.serverError', { status: res.status }));\n}","handlingStrategy":"try-catch","validationCode":"const authState = getAuthState();\nif (isPro && !authState.user) { promptSignIn(); return; }\nif (!navigator.onLine) { queueOffline(); return; }","typeGuard":null,"tryCatchPattern":"try {\n  const res = await fetch(widgetAgentUrl(), { method: 'POST', headers, body, signal });\n  if (!res.ok) {\n    if (res.status === 401 || res.status === 403) await promptReauth();\n    else if (res.status >= 500 || res.status === 429) scheduleRetry();\n    throw new Error(t('widgets.serverError', { status: res.status }));\n  }\n} catch (e) {\n  setFooterStatus(footerEl, e instanceof Error ? e.message : String(e), 'error');\n}","preventionTips":["Rebuild auth headers via buildWidgetAuthHeaders() immediately before each request","Capture requestBelief/userId before the fetch so desync reports carry context, mirroring the modal","Handle 401/403/429/5xx distinctly instead of flattening to one message","Monitor reportWidgetEntitlementDesync output to catch client/server entitlement divergence early"],"tags":["http-status","widget-agent","entitlement","auth","fetch"],"backgroundTag":"http-error-status","analyzedSha":"eeab0a219fce0f02a00603b532dbae9041b934ac","analyzedAt":"2026-08-21T16:51:25.751Z","schemaVersion":2},"datasetVersion":"2026-08-23T13:39:53.451Z"}