From 0a35ce419bce11a80aae9b9a0024e4a11875c262 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:07:42 -0700 Subject: [PATCH 1/3] Add redirect canonicalization probe --- domains/ably.com/integrations.json | 14 + domains/ably.io/integrations.json | 38 - domains/ably.net/integrations.json | 30 - domains/adobe.com/integrations.json | 82 +++ domains/adobe.io/integrations.json | 89 --- domains/alpic.ai/integrations.json | 7 + domains/alpic.live/integrations.json | 43 -- domains/amentum.io/integrations.json | 50 ++ domains/amentum.space/integrations.json | 67 -- .../apidocumentation.com/integrations.json | 7 - domains/auraintel.com/integrations.json | 14 - .../auraintelligence.com/integrations.json | 15 - .../integrations.json | 45 -- domains/bclaws.ca/integrations.json | 16 - domains/buffer.com/integrations.json | 7 + domains/bufferapp.com/integrations.json | 37 - domains/docspring.com/integrations.json | 7 + domains/domotz.app/integrations.json | 37 - domains/domotz.com/integrations.json | 8 + domains/dropbox.com/integrations.json | 15 + domains/ethos.com/integrations.json | 13 + domains/ethoslife.com/integrations.json | 21 - domains/factset.com/integrations.json | 2 +- domains/fastmcp.cloud/integrations.json | 22 - domains/formapi.io/integrations.json | 15 - domains/getaura.ai/integrations.json | 3 +- domains/getdbt.com/integrations.json | 2 +- domains/getmiso.com/integrations.json | 7 - domains/getzep.com/integrations.json | 2 +- domains/gitea.com/integrations.json | 7 + domains/gitea.io/integrations.json | 15 - domains/github.com/integrations.json | 5 + domains/goshippo.com/integrations.json | 2 +- domains/gov.bc.ca/integrations.json | 9 + domains/graphite.com/integrations.json | 2 +- domains/hellosign.com/integrations.json | 23 - domains/intercom.com/integrations.json | 2 +- domains/konghq.com/integrations.json | 7 + domains/mashape.com/integrations.json | 15 - domains/massive.com/integrations.json | 1 + domains/mdsol.com/integrations.json | 29 - domains/medidata.com/integrations.json | 21 + domains/monoscope.tech/integrations.json | 2 +- domains/motos.net/integrations.json | 7 - domains/nexmo.com/integrations.json | 68 -- .../nfusionsolutions.biz/integrations.json | 16 - .../nfusionsolutions.com/integrations.json | 8 + domains/planetscale.com/integrations.json | 9 + domains/polygon.io/integrations.json | 37 - domains/prefect.io/integrations.json | 7 + domains/pscale.dev/integrations.json | 45 -- domains/railway.com/integrations.json | 2 +- domains/readme.com/integrations.json | 14 + domains/readme.io/integrations.json | 29 - domains/rumble.run/integrations.json | 16 - domains/runzero.com/integrations.json | 8 + domains/sitejabber.com/integrations.json | 7 - domains/strivemath.com/integrations.json | 7 + domains/strivemaths.com/integrations.json | 15 - domains/twitter.com/integrations.json | 49 -- domains/vonage.com/integrations.json | 61 ++ domains/watchful.li/integrations.json | 15 - domains/x.com/integrations.json | 14 + domains/zoho.com/integrations.json | 21 + domains/zoho.eu/integrations.json | 22 - domains/zoho.in/integrations.json | 37 - domains/zoho.sa/integrations.json | 7 - domains/zohocloud.ca/integrations.json | 7 - output-last-message | 669 ++++++++++++++++++ package.json | 1 + scripts/batch/dedup.test.ts | 33 + scripts/batch/dedup.ts | 31 +- scripts/batch/discovered-catalog.test.ts | 119 +++- scripts/batch/discovered-catalog.ts | 180 ++++- scripts/batch/redirect-canonical.test.ts | 72 ++ scripts/batch/redirect-canonical.ts | 545 ++++++++++++++ scripts/batch/sync-kv.ts | 54 +- src/lib/domain-aliases.test.ts | 1 + src/lib/domain-aliases.ts | 35 + 79 files changed, 2119 insertions(+), 1004 deletions(-) delete mode 100644 domains/ably.io/integrations.json delete mode 100644 domains/ably.net/integrations.json delete mode 100644 domains/adobe.io/integrations.json delete mode 100644 domains/alpic.live/integrations.json delete mode 100644 domains/amentum.space/integrations.json delete mode 100644 domains/apidocumentation.com/integrations.json delete mode 100644 domains/auraintel.com/integrations.json delete mode 100644 domains/auraintelligence.com/integrations.json delete mode 100644 domains/avatars1.githubusercontent.com/integrations.json delete mode 100644 domains/bclaws.ca/integrations.json delete mode 100644 domains/bufferapp.com/integrations.json delete mode 100644 domains/domotz.app/integrations.json delete mode 100644 domains/ethoslife.com/integrations.json delete mode 100644 domains/fastmcp.cloud/integrations.json delete mode 100644 domains/formapi.io/integrations.json delete mode 100644 domains/getmiso.com/integrations.json delete mode 100644 domains/gitea.io/integrations.json delete mode 100644 domains/hellosign.com/integrations.json delete mode 100644 domains/mashape.com/integrations.json delete mode 100644 domains/mdsol.com/integrations.json delete mode 100644 domains/motos.net/integrations.json delete mode 100644 domains/nexmo.com/integrations.json delete mode 100644 domains/nfusionsolutions.biz/integrations.json delete mode 100644 domains/polygon.io/integrations.json delete mode 100644 domains/pscale.dev/integrations.json delete mode 100644 domains/readme.io/integrations.json delete mode 100644 domains/rumble.run/integrations.json delete mode 100644 domains/sitejabber.com/integrations.json delete mode 100644 domains/strivemaths.com/integrations.json delete mode 100644 domains/twitter.com/integrations.json delete mode 100644 domains/watchful.li/integrations.json delete mode 100644 domains/zoho.eu/integrations.json delete mode 100644 domains/zoho.in/integrations.json delete mode 100644 domains/zoho.sa/integrations.json delete mode 100644 domains/zohocloud.ca/integrations.json create mode 100644 output-last-message create mode 100644 scripts/batch/redirect-canonical.test.ts create mode 100644 scripts/batch/redirect-canonical.ts diff --git a/domains/ably.com/integrations.json b/domains/ably.com/integrations.json index b4eb358e..6e0ad821 100644 --- a/domains/ably.com/integrations.json +++ b/domains/ably.com/integrations.json @@ -27,6 +27,11 @@ "identifier": "@ably/cli", "registryType": "npm", "runtimeHint": "Node.js" + }, + { + "identifier": "@ably/cli", + "registryType": "npm", + "runtimeHint": "node" } ], "slug": "ably-cli", @@ -36,8 +41,17 @@ "authStatus": "required", "name": "Ably Control API", "slug": "ably-control-api", + "spec": "https://raw.githubusercontent.com/ably/docs/main/static/open-specs/control-v1.yaml", "type": "http", "url": "https://control.ably.net/v1" + }, + { + "authStatus": "required", + "name": "Ably Platform REST API", + "slug": "ably-platform-rest-api", + "spec": "https://api.apis.guru/v2/specs/ably.io/platform/1.1.0/openapi.json", + "type": "http", + "url": "https://rest.ably.io" } ] } diff --git a/domains/ably.io/integrations.json b/domains/ably.io/integrations.json deleted file mode 100644 index 471d91a2..00000000 --- a/domains/ably.io/integrations.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "description": "Ably provides realtime messaging infrastructure for applications, including pub/sub, chat, synchronization, and related developer tooling. It offers hosted APIs and tools for both message/data-plane access and account/application management.", - "discoveredAt": "2026-07-02T21:08:26.164Z", - "domain": "ably.io", - "summary": "Ably exposes two documented HTTP APIs—a data-plane Platform REST API at `https://rest.ably.io` and a management Control API at `https://control.ably.net/v1`—plus the npm-distributed `ably` CLI; no public GraphQL or MCP server surfaced.", - "surfaces": [ - { - "authStatus": "required", - "command": "ably", - "name": "Ably CLI", - "packages": [ - { - "identifier": "@ably/cli", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "ably-cli", - "type": "cli" - }, - { - "authStatus": "required", - "name": "Ably Control API", - "slug": "ably-control-api", - "spec": "https://raw.githubusercontent.com/ably/docs/main/static/open-specs/control-v1.yaml", - "type": "http", - "url": "https://control.ably.net/v1" - }, - { - "authStatus": "required", - "name": "Ably Platform REST API", - "slug": "ably-platform-rest-api", - "spec": "https://api.apis.guru/v2/specs/ably.io/platform/1.1.0/openapi.json", - "type": "http", - "url": "https://rest.ably.io" - } - ] -} diff --git a/domains/ably.net/integrations.json b/domains/ably.net/integrations.json deleted file mode 100644 index 75fd9fdc..00000000 --- a/domains/ably.net/integrations.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "description": "Ably is a realtime application infrastructure platform for pub/sub messaging, chat, synchronization, and related realtime features. It provides hosted services and tooling for managing applications and messaging resources.", - "discoveredAt": "2026-07-02T21:08:46.532Z", - "domain": "ably.net", - "summary": "Ably exposes a documented Control REST API with an OpenAPI spec at `https://control.ably.net/v1`, plus an npm-distributed `ably` CLI authenticated either by CLI OAuth login or an Ably access token via `ABLY_ACCESS_TOKEN`.", - "surfaces": [ - { - "authStatus": "required", - "name": "Ably Control API", - "slug": "ably-control-api", - "spec": "https://raw.githubusercontent.com/ably/open-specs/main/definitions/control-v1.yaml", - "type": "http", - "url": "https://control.ably.net/v1" - }, - { - "authStatus": "required", - "command": "ably", - "name": "Ably CLI", - "packages": [ - { - "identifier": "@ably/cli", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "ably-cli", - "type": "cli" - } - ] -} diff --git a/domains/adobe.com/integrations.json b/domains/adobe.com/integrations.json index f59202a4..9b6c9d32 100644 --- a/domains/adobe.com/integrations.json +++ b/domains/adobe.com/integrations.json @@ -1,4 +1,5 @@ { + "description": "Adobe Developer provides access to APIs, events, and tooling across Adobe products including Experience Cloud, Creative Cloud, Document Cloud, and App Builder. It also publishes MCP connectors that let AI clients access Adobe capabilities such as creativity, marketing, journey optimization, express, Photoshop, and Acrobat.", "discoveredAt": "2026-07-02T21:10:09.793Z", "domain": "adobe.com", "summary": "Adobe exposes HTTP APIs including AEM OpenAPI-based APIs, Adobe Experience Platform APIs, and Adobe Commerce REST APIs; recorded auth uses Adobe Developer Console OAuth bearer tokens for AEM, bearer token + `x-api-key` + `x-gw-ims-org-id` for Experience Platform, and Commerce REST auth remains undetermined from the recorded docs.", @@ -20,6 +21,87 @@ "name": "Adobe Commerce REST APIs", "slug": "adobe-commerce-rest-apis", "type": "http" + }, + { + "authStatus": "required", + "name": "Adobe Marketing Agent MCP", + "slug": "adobe-marketing-agent-mcp", + "type": "mcp", + "url": "https://aep-ai-ama.adobe.io/mcp" + }, + { + "authStatus": "required", + "name": "Adobe for creativity MCP", + "slug": "adobe-for-creativity-mcp", + "type": "mcp", + "url": "https://adobe-creativity.adobe.io/mcp" + }, + { + "authStatus": "required", + "name": "Adobe Journey Optimizer MCP", + "slug": "adobe-journey-optimizer-mcp", + "type": "mcp", + "url": "https://ajo-mcp.adobe.io/mcp" + }, + { + "authStatus": "none", + "name": "Adobe Express MCP", + "slug": "adobe-express-mcp", + "type": "mcp", + "url": "https://express-mcp-service.adobe.io/mcp" + }, + { + "authStatus": "none", + "name": "Adobe Photoshop MCP", + "slug": "adobe-photoshop-mcp", + "type": "mcp", + "url": "https://photoshop-mcp-service.adobe.io/mcp" + }, + { + "authStatus": "none", + "name": "Adobe Acrobat MCP", + "slug": "adobe-acrobat-mcp", + "type": "mcp", + "url": "https://acrobat-mcp.adobe.io/mcp/call" + }, + { + "authStatus": "required", + "name": "Adobe Cloud Manager API", + "slug": "adobe-cloud-manager-api", + "type": "http" + }, + { + "authStatus": "unknown", + "name": "Adobe I/O Events API", + "slug": "adobe-i-o-events-api", + "type": "http" + }, + { + "authStatus": "required", + "name": "Adobe Data Collection APIs", + "slug": "adobe-data-collection-apis", + "type": "http" + }, + { + "authStatus": "required", + "name": "Adobe Journey Optimizer APIs", + "slug": "adobe-journey-optimizer-apis", + "type": "http", + "url": "https://cjm.adobe.io/imp/" + }, + { + "authStatus": "required", + "command": "aio", + "name": "Adobe I/O CLI", + "packages": [ + { + "identifier": "@adobe/aio-cli", + "registryType": "npm", + "runtimeHint": "node" + } + ], + "slug": "adobe-i-o-cli", + "type": "cli" } ] } diff --git a/domains/adobe.io/integrations.json b/domains/adobe.io/integrations.json deleted file mode 100644 index 739291d2..00000000 --- a/domains/adobe.io/integrations.json +++ /dev/null @@ -1,89 +0,0 @@ -{ - "description": "Adobe Developer provides access to APIs, events, and tooling across Adobe products including Experience Cloud, Creative Cloud, Document Cloud, and App Builder. It also publishes MCP connectors that let AI clients access Adobe capabilities such as creativity, marketing, journey optimization, express, Photoshop, and Acrobat.", - "discoveredAt": "2026-07-02T21:10:00.876Z", - "domain": "adobe.io", - "summary": "Adobe exposes multiple MCP servers for creative, marketing, journey, express, Photoshop, and Acrobat workflows, plus documented HTTP APIs such as Cloud Manager, Data Collection, Journey Optimizer, and Events, and an npm-distributed `aio` CLI.", - "surfaces": [ - { - "authStatus": "required", - "name": "Adobe Marketing Agent MCP", - "slug": "adobe-marketing-agent-mcp", - "type": "mcp", - "url": "https://aep-ai-ama.adobe.io/mcp" - }, - { - "authStatus": "required", - "name": "Adobe for creativity MCP", - "slug": "adobe-for-creativity-mcp", - "type": "mcp", - "url": "https://adobe-creativity.adobe.io/mcp" - }, - { - "authStatus": "required", - "name": "Adobe Journey Optimizer MCP", - "slug": "adobe-journey-optimizer-mcp", - "type": "mcp", - "url": "https://ajo-mcp.adobe.io/mcp" - }, - { - "authStatus": "none", - "name": "Adobe Express MCP", - "slug": "adobe-express-mcp", - "type": "mcp", - "url": "https://express-mcp-service.adobe.io/mcp" - }, - { - "authStatus": "none", - "name": "Adobe Photoshop MCP", - "slug": "adobe-photoshop-mcp", - "type": "mcp", - "url": "https://photoshop-mcp-service.adobe.io/mcp" - }, - { - "authStatus": "none", - "name": "Adobe Acrobat MCP", - "slug": "adobe-acrobat-mcp", - "type": "mcp", - "url": "https://acrobat-mcp.adobe.io/mcp/call" - }, - { - "authStatus": "required", - "name": "Adobe Cloud Manager API", - "slug": "adobe-cloud-manager-api", - "type": "http" - }, - { - "authStatus": "unknown", - "name": "Adobe I/O Events API", - "slug": "adobe-i-o-events-api", - "type": "http" - }, - { - "authStatus": "required", - "name": "Adobe Data Collection APIs", - "slug": "adobe-data-collection-apis", - "type": "http" - }, - { - "authStatus": "required", - "name": "Adobe Journey Optimizer APIs", - "slug": "adobe-journey-optimizer-apis", - "type": "http", - "url": "https://cjm.adobe.io/imp/" - }, - { - "authStatus": "required", - "command": "aio", - "name": "Adobe I/O CLI", - "packages": [ - { - "identifier": "@adobe/aio-cli", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "adobe-i-o-cli", - "type": "cli" - } - ] -} diff --git a/domains/alpic.ai/integrations.json b/domains/alpic.ai/integrations.json index 6977f53b..fcde9b79 100644 --- a/domains/alpic.ai/integrations.json +++ b/domains/alpic.ai/integrations.json @@ -32,6 +32,13 @@ "spec": "https://api.alpic.ai/openapi.json", "type": "http", "url": "https://api.alpic.ai/v1" + }, + { + "authStatus": "required", + "name": "Noodle Seed MCP Server", + "slug": "noodle-seed-mcp-server", + "type": "mcp", + "url": "https://noodleseed-chatgpt-a-5b531721.alpic.live/" } ] } diff --git a/domains/alpic.live/integrations.json b/domains/alpic.live/integrations.json deleted file mode 100644 index 2c783be3..00000000 --- a/domains/alpic.live/integrations.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "description": "Alpic is a cloud platform for building, deploying, hosting, and distributing MCP servers and ChatGPT apps. It also provides analytics, monitoring, testing, and related developer tooling for those AI-native applications.", - "discoveredAt": "2026-07-02T21:10:01.475Z", - "domain": "alpic.live", - "summary": "Alpic exposes a bearer-authenticated REST API at `https://api.alpic.ai/v1`, an OAuth-protected MCP server at `https://mcp.alpic.ai/`, a cataloged OAuth MCP server on `*.alpic.live`, and the `alpic` npm CLI authenticated via browser login or `ALPIC_API_KEY`.", - "surfaces": [ - { - "authStatus": "required", - "name": "Alpic REST API", - "slug": "alpic-rest-api", - "type": "http", - "url": "https://api.alpic.ai/v1" - }, - { - "authStatus": "required", - "name": "Alpic MCP Server", - "slug": "alpic-mcp-server", - "type": "mcp", - "url": "https://mcp.alpic.ai/" - }, - { - "authStatus": "required", - "name": "Noodle Seed MCP Server", - "slug": "noodle-seed-mcp-server", - "type": "mcp", - "url": "https://noodleseed-chatgpt-a-5b531721.alpic.live/" - }, - { - "authStatus": "required", - "command": "alpic", - "name": "Alpic CLI", - "packages": [ - { - "identifier": "alpic", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "alpic-cli", - "type": "cli" - } - ] -} diff --git a/domains/amentum.io/integrations.json b/domains/amentum.io/integrations.json index 31e7d399..473d347d 100644 --- a/domains/amentum.io/integrations.json +++ b/domains/amentum.io/integrations.json @@ -13,6 +13,10 @@ "identifier": "git+https://github.com/amentumspace/amentum-cli.git", "registryType": "other", "runtimeHint": "uv tool install" + }, + { + "identifier": "amentumspace/amentum-cli", + "registryType": "github" } ], "slug": "amentum-cli", @@ -57,6 +61,52 @@ "spec": "https://avrad.amentum.io/openapi.json", "type": "http", "url": "https://avrad.amentum.io" + }, + { + "authStatus": "required", + "name": "Gravity API", + "slug": "gravity-api", + "spec": "https://api.apis.guru/v2/specs/amentum.space/gravity/1.1.1/openapi.json", + "type": "http", + "url": "https://gravity.amentum.io" + }, + { + "authStatus": "required", + "name": "Atmosphere API", + "slug": "atmosphere-api", + "spec": "https://api.apis.guru/v2/specs/amentum.space/atmosphere/1.1.1/openapi.json", + "type": "http", + "url": "https://atmosphere.amentum.io" + }, + { + "authStatus": "required", + "name": "Space Radiation API", + "slug": "space-radiation-api", + "spec": "https://api.apis.guru/v2/specs/amentum.space/space_radiation/1.1.2/openapi.json", + "type": "http", + "url": "https://spaceradiation.amentum.space" + }, + { + "authStatus": "required", + "name": "Geomag API", + "slug": "geomag-api", + "spec": "https://api.apis.guru/v2/specs/amentum.space/global-magnet/1.3.0/openapi.json", + "type": "http", + "url": "https://geomag.amentum.io/wmm" + }, + { + "authStatus": "required", + "name": "Aviation Radiation API", + "slug": "aviation-radiation-api", + "type": "http", + "url": "https://avrad.amentum.io" + }, + { + "authStatus": "required", + "name": "Amentum MCP server", + "slug": "amentum-mcp-server", + "type": "mcp", + "url": "https://mcp.amentum.io/mcp/{service}/" } ] } diff --git a/domains/amentum.space/integrations.json b/domains/amentum.space/integrations.json deleted file mode 100644 index 6a7211a0..00000000 --- a/domains/amentum.space/integrations.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "description": "Amentum Scientific provides model-backed environmental and geospatial data services, including atmosphere, radiation, geomagnetic, gravity, and ocean intelligence. Its products are aimed at operational software, aviation, geospatial, and agentic workflows.", - "discoveredAt": "2026-07-02T21:10:03.854Z", - "domain": "amentum.space", - "summary": "Amentum Scientific exposes five authenticated HTTP APIs (atmosphere, aviation radiation, geomagnetic, gravity, and space radiation), plus an authenticated MCP server and a CLI, all using the same developer-issued API key.", - "surfaces": [ - { - "authStatus": "required", - "name": "Gravity API", - "slug": "gravity-api", - "spec": "https://api.apis.guru/v2/specs/amentum.space/gravity/1.1.1/openapi.json", - "type": "http", - "url": "https://gravity.amentum.io" - }, - { - "authStatus": "required", - "name": "Atmosphere API", - "slug": "atmosphere-api", - "spec": "https://api.apis.guru/v2/specs/amentum.space/atmosphere/1.1.1/openapi.json", - "type": "http", - "url": "https://atmosphere.amentum.io" - }, - { - "authStatus": "required", - "name": "Space Radiation API", - "slug": "space-radiation-api", - "spec": "https://api.apis.guru/v2/specs/amentum.space/space_radiation/1.1.2/openapi.json", - "type": "http", - "url": "https://spaceradiation.amentum.space" - }, - { - "authStatus": "required", - "name": "Geomag API", - "slug": "geomag-api", - "spec": "https://api.apis.guru/v2/specs/amentum.space/global-magnet/1.3.0/openapi.json", - "type": "http", - "url": "https://geomag.amentum.io/wmm" - }, - { - "authStatus": "required", - "name": "Aviation Radiation API", - "slug": "aviation-radiation-api", - "type": "http", - "url": "https://avrad.amentum.io" - }, - { - "authStatus": "required", - "name": "Amentum MCP server", - "slug": "amentum-mcp-server", - "type": "mcp", - "url": "https://mcp.amentum.io/mcp/{service}/" - }, - { - "authStatus": "required", - "command": "amentum", - "name": "amentum-cli", - "packages": [ - { - "identifier": "amentumspace/amentum-cli", - "registryType": "github" - } - ], - "slug": "amentum-cli", - "type": "cli" - } - ] -} diff --git a/domains/apidocumentation.com/integrations.json b/domains/apidocumentation.com/integrations.json deleted file mode 100644 index ad93b7e7..00000000 --- a/domains/apidocumentation.com/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "apidocumentation.com serves Scalar website content about documentation, SDK, registry, API client, and MCP products. The pages on this domain point developers to Scalar-owned properties such as scalar.com, dashboard.scalar.com, and client.scalar.com rather than exposing separate APIs, GraphQL endpoints, MCP connect endpoints, or CLIs on apidocumentation.com itself.", - "discoveredAt": "2026-07-02T22:50:35.885Z", - "domain": "apidocumentation.com", - "summary": "apidocumentation.com is a branded alias of Scalar’s marketing/docs site and does not expose a distinct public developer integration surface on this domain.", - "surfaces": [] -} diff --git a/domains/auraintel.com/integrations.json b/domains/auraintel.com/integrations.json deleted file mode 100644 index dc4d5662..00000000 --- a/domains/auraintel.com/integrations.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "description": "Aura Intelligence provides workforce intelligence and analytics, aggregating and analyzing large-scale company workforce data for consulting, investment, and enterprise use cases. Its product focuses on company benchmarking, hiring and compensation insights, and related research workflows.", - "discoveredAt": "2026-07-02T22:54:00.173Z", - "domain": "auraintel.com", - "summary": "Aura exposes a documented hosted MCP server for AI assistants; its marketing pages also mention an API, but no public API docs, base URL, machine-readable spec, CLI, or GraphQL endpoint were found on auraintel.com.", - "surfaces": [ - { - "authStatus": "unknown", - "name": "Aura MCP Server", - "slug": "aura-mcp-server", - "type": "mcp" - } - ] -} diff --git a/domains/auraintelligence.com/integrations.json b/domains/auraintelligence.com/integrations.json deleted file mode 100644 index 2854aa4b..00000000 --- a/domains/auraintelligence.com/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "Aura Intelligence provides workforce data and analytics, using AI to analyze large-scale company and labor-market information. It is used for workforce benchmarking, company research, due diligence, and related business intelligence workflows.", - "discoveredAt": "2026-07-02T21:16:07.390Z", - "domain": "auraintelligence.com", - "summary": "Aura Intelligence exposes a hosted MCP server at `https://mcp.auraintelligence.com/mcp`; its marketing site also mentions a robust API, but no public API docs, spec, CLI, or GraphQL endpoint were found.", - "surfaces": [ - { - "authStatus": "unknown", - "name": "Aura MCP Server", - "slug": "aura-mcp-server", - "type": "mcp", - "url": "https://mcp.auraintelligence.com/mcp" - } - ] -} diff --git a/domains/avatars1.githubusercontent.com/integrations.json b/domains/avatars1.githubusercontent.com/integrations.json deleted file mode 100644 index 72b73e56..00000000 --- a/domains/avatars1.githubusercontent.com/integrations.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "description": "GitHub is a source code hosting and collaboration platform for Git repositories, issues, pull requests, automation, and related developer workflows. The queried host `avatars1.githubusercontent.com` is part of GitHub’s infrastructure, and the public developer integration surfaces for this service are GitHub’s APIs and CLI.", - "discoveredAt": "2026-07-02T22:55:13.061Z", - "domain": "avatars1.githubusercontent.com", - "summary": "GitHub exposes a versioned REST API at `api.github.com`, a GraphQL API at `api.github.com/graphql`, and the `gh` CLI; all use GitHub-issued tokens such as personal access tokens, GitHub App tokens, or OAuth app tokens depending on surface.", - "surfaces": [ - { - "authStatus": "required", - "name": "GitHub REST API", - "slug": "github-rest-api", - "spec": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", - "type": "http", - "url": "https://api.github.com" - }, - { - "authStatus": "required", - "name": "GitHub GraphQL API", - "slug": "github-graphql-api", - "spec": "https://docs.github.com/public/fpt/schema.docs.graphql", - "type": "graphql", - "url": "https://api.github.com/graphql" - }, - { - "authStatus": "required", - "command": "gh", - "name": "GitHub CLI", - "packages": [ - { - "identifier": "gh", - "registryType": "homebrew" - }, - { - "identifier": "GitHub.cli", - "registryType": "winget" - }, - { - "identifier": "gh", - "registryType": "apt" - } - ], - "slug": "github-cli", - "type": "cli" - } - ] -} diff --git a/domains/bclaws.ca/integrations.json b/domains/bclaws.ca/integrations.json deleted file mode 100644 index b61030f5..00000000 --- a/domains/bclaws.ca/integrations.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "description": "BC Laws is an online library of British Columbia legislation and related legal materials published by the Province of British Columbia. It provides web access and raw XML API access to browse, search, and retrieve legal documents.", - "discoveredAt": "2026-07-02T21:16:58.596Z", - "domain": "bclaws.ca", - "summary": "Public HTTP BC Laws API (OpenAPI available) for searching and retrieving British Columbia legislation and related materials; no auth documented.", - "surfaces": [ - { - "authStatus": "none", - "name": "BC Laws API", - "slug": "bc-laws-api", - "spec": "https://api.apis.guru/v2/specs/bclaws.ca/bclaws/1.0.0/openapi.json", - "type": "http", - "url": "https://www.bclaws.ca/civix" - } - ] -} diff --git a/domains/buffer.com/integrations.json b/domains/buffer.com/integrations.json index 6fbaf578..6d6977c5 100644 --- a/domains/buffer.com/integrations.json +++ b/domains/buffer.com/integrations.json @@ -25,6 +25,13 @@ ], "slug": "buffer-cli", "type": "cli" + }, + { + "authStatus": "required", + "name": "Buffer Legacy REST API", + "slug": "buffer-legacy-rest-api", + "type": "http", + "url": "https://api.bufferapp.com/1/" } ] } diff --git a/domains/bufferapp.com/integrations.json b/domains/bufferapp.com/integrations.json deleted file mode 100644 index d88aeba1..00000000 --- a/domains/bufferapp.com/integrations.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "description": "Buffer is a social media management platform for creating, scheduling, and analyzing posts across multiple social channels. It lets users and teams manage organizations, channels, posts, and ideas from one service.", - "discoveredAt": "2026-07-03T15:18:46.278Z", - "domain": "bufferapp.com", - "summary": "Buffer exposes a GraphQL API at `https://api.buffer.com`, a deprecated legacy REST API at `https://api.bufferapp.com/1/`, and a `buffer` CLI distributed as `@bufferapp/cli`; authentication is via personal API key for GraphQL/CLI or OAuth app credentials for app-style access and the legacy REST API.", - "surfaces": [ - { - "authStatus": "required", - "name": "Buffer GraphQL API", - "slug": "buffer-graphql-api", - "spec": "introspection", - "type": "graphql", - "url": "https://api.buffer.com" - }, - { - "authStatus": "required", - "name": "Buffer Legacy REST API", - "slug": "buffer-legacy-rest-api", - "type": "http", - "url": "https://api.bufferapp.com/1/" - }, - { - "authStatus": "required", - "command": "buffer", - "name": "Buffer CLI", - "packages": [ - { - "identifier": "@bufferapp/cli", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "buffer-cli", - "type": "cli" - } - ] -} diff --git a/domains/docspring.com/integrations.json b/domains/docspring.com/integrations.json index e5e59746..76e57480 100644 --- a/domains/docspring.com/integrations.json +++ b/domains/docspring.com/integrations.json @@ -10,6 +10,13 @@ "slug": "docspring-rest-api", "type": "http", "url": "https://sync.api.docspring.com/api/v1" + }, + { + "authStatus": "required", + "name": "DocSpring API v1", + "slug": "docspring-api-v1", + "type": "http", + "url": "https://sync.api.docspring.com/api/v1" } ] } diff --git a/domains/domotz.app/integrations.json b/domains/domotz.app/integrations.json deleted file mode 100644 index 18b73789..00000000 --- a/domains/domotz.app/integrations.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "description": "Domotz is a cloud-based network monitoring and management platform for MSPs, IT teams, and enterprise environments. It provides device discovery, topology and status monitoring, remote access, diagnostics, and related network operations features across distributed sites.", - "discoveredAt": "2026-07-02T21:31:35.169Z", - "domain": "domotz.app", - "summary": "Domotz exposes two HTTP APIs—a Public REST API authenticated by `X-Api-Key` and a Marketplace API with a published OpenAPI spec using Basic auth—plus a remote MCP server authenticated with Domotz OAuth.", - "surfaces": [ - { - "authStatus": "required", - "name": "Domotz MCP Server", - "slug": "domotz-mcp-server", - "type": "mcp", - "url": "https://mcp.ov.domotz.app/mcp" - }, - { - "authStatus": "required", - "name": "Domotz MCP Server (documented endpoint)", - "slug": "domotz-mcp-server-documented-endpoint", - "type": "mcp", - "url": "https://mcp.domotz.com/mcp" - }, - { - "authStatus": "required", - "name": "Domotz Public API", - "slug": "domotz-public-api", - "type": "http", - "url": "https://api-us-east-1-cell-1.domotz.com/public-api/v1" - }, - { - "authStatus": "required", - "name": "Domotz Marketplace API", - "slug": "domotz-marketplace-api", - "spec": "https://portal.domotz.com/openapi.json", - "type": "http", - "url": "https://portal.domotz.com/marketplace-api/v2" - } - ] -} diff --git a/domains/domotz.com/integrations.json b/domains/domotz.com/integrations.json index df2008e9..8ac0ada2 100644 --- a/domains/domotz.com/integrations.json +++ b/domains/domotz.com/integrations.json @@ -17,6 +17,14 @@ "slug": "domotz-public-api", "type": "http", "url": "https://api-us-east-1-cell-1.domotz.com/public-api/v1" + }, + { + "authStatus": "required", + "name": "Domotz Marketplace API", + "slug": "domotz-marketplace-api", + "spec": "https://portal.domotz.com/openapi.json", + "type": "http", + "url": "https://portal.domotz.com/marketplace-api/v2" } ] } diff --git a/domains/dropbox.com/integrations.json b/domains/dropbox.com/integrations.json index 5dc6119e..7841daba 100644 --- a/domains/dropbox.com/integrations.json +++ b/domains/dropbox.com/integrations.json @@ -31,6 +31,21 @@ "slug": "dropbox-chooser-drop-ins-javascript", "type": "http", "url": "https://www.dropbox.com/static/api/2/dropins.js" + }, + { + "authStatus": "required", + "name": "Dropbox Sign API", + "slug": "dropbox-sign-api", + "spec": "https://raw.githubusercontent.com/hellosign/hellosign-openapi/main/openapi.yaml", + "type": "http", + "url": "https://api.hellosign.com/v3" + }, + { + "authStatus": "unknown", + "name": "Dropbox Sign docs MCP server", + "slug": "dropbox-sign-docs-mcp-server", + "type": "mcp", + "url": "https://developers.hellosign.com/_mcp/server" } ] } diff --git a/domains/ethos.com/integrations.json b/domains/ethos.com/integrations.json index 5ccf721d..49231ceb 100644 --- a/domains/ethos.com/integrations.json +++ b/domains/ethos.com/integrations.json @@ -10,6 +10,19 @@ "slug": "ethos-api-v2", "type": "http", "url": "https://api.ethos.network/api/v2" + }, + { + "authStatus": "none", + "name": "Ethos MCP Server", + "slug": "ethos-mcp-server", + "type": "mcp", + "url": "https://eg.ethoslife.com/mcp" + }, + { + "authStatus": "unknown", + "name": "Ethos Partner API", + "slug": "ethos-partner-api", + "type": "http" } ] } diff --git a/domains/ethoslife.com/integrations.json b/domains/ethoslife.com/integrations.json deleted file mode 100644 index 77cb8d14..00000000 --- a/domains/ethoslife.com/integrations.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "description": "Ethos is an online life insurance and estate-planning service. It sells life insurance directly and through agents and partners, including embedded partner integrations for quoting, underwriting, and policy application flows.", - "discoveredAt": "2026-07-02T21:36:08.436Z", - "domain": "ethoslife.com", - "summary": "Ethos exposes a public MCP endpoint at https://eg.ethoslife.com/mcp and documents a partner HTTP API for embedded life-insurance workflows, but public docs do not provide a machine-readable spec, base URL, or enough binding detail to determine authentication mechanics conclusively.", - "surfaces": [ - { - "authStatus": "none", - "name": "Ethos MCP Server", - "slug": "ethos-mcp-server", - "type": "mcp", - "url": "https://eg.ethoslife.com/mcp" - }, - { - "authStatus": "unknown", - "name": "Ethos Partner API", - "slug": "ethos-partner-api", - "type": "http" - } - ] -} diff --git a/domains/factset.com/integrations.json b/domains/factset.com/integrations.json index ea88d4ae..d839fe64 100644 --- a/domains/factset.com/integrations.json +++ b/domains/factset.com/integrations.json @@ -1,7 +1,7 @@ { "description": "FactSet provides financial data, analytics, and related content products for investment research, market data, and enterprise workflows. Its offerings include many domain-specific APIs and an AI-ready MCP server for accessing curated FactSet content from LLM clients.", "discoveredAt": "2026-07-02T23:14:05.936Z", - "domain": "factset.io", + "domain": "factset.com", "summary": "FactSet exposes a broad HTTP API platform at api.factset.com documented in its API catalog, plus an MCP server at https://mcp.factset.com/content/v1; no public GraphQL API or CLI was found.", "surfaces": [ { diff --git a/domains/fastmcp.cloud/integrations.json b/domains/fastmcp.cloud/integrations.json deleted file mode 100644 index c3548a56..00000000 --- a/domains/fastmcp.cloud/integrations.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "description": "FastMCP Cloud, now presented as Prefect Horizon, is a managed platform for deploying and governing MCP servers. It lets users connect GitHub-hosted FastMCP projects to hosted MCP endpoints with built-in authentication and access control.", - "discoveredAt": "2026-07-02T20:23:29.906Z", - "domain": "fastmcp.cloud", - "summary": "fastmcp.cloud markets Prefect Horizon, which exposes hosted remote MCP server endpoints; public docs show tenant/server URLs but do not document a first-party REST/GraphQL API, CLI, or Horizon-issued developer credential for the fastmcp.cloud domain itself.", - "surfaces": [ - { - "authStatus": "unknown", - "name": "Horizon-hosted MCP server endpoint", - "slug": "horizon-hosted-mcp-server-endpoint", - "type": "mcp", - "url": "https://your-server-name.fastmcp.app/mcp" - }, - { - "authStatus": "unknown", - "name": "Horizon-hosted MCP server endpoint", - "slug": "horizon-hosted-mcp-server-endpoint-2", - "type": "mcp", - "url": "https://my-tools.horizon.prefect.io" - } - ] -} diff --git a/domains/formapi.io/integrations.json b/domains/formapi.io/integrations.json deleted file mode 100644 index 918de9ef..00000000 --- a/domains/formapi.io/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "FormAPI was rebranded as DocSpring, a service for generating, filling, combining, and signing PDF documents from templates and structured data. Its public developer surface is an API used to automate document generation and related workflows.", - "discoveredAt": "2026-07-02T21:41:28.253Z", - "domain": "formapi.io", - "summary": "formapi.io now points developers to DocSpring’s authenticated HTTP API for PDF generation, template management, submissions, data requests, file uploads, folders, and PDF combination.", - "surfaces": [ - { - "authStatus": "required", - "name": "DocSpring API v1", - "slug": "docspring-api-v1", - "type": "http", - "url": "https://sync.api.docspring.com/api/v1" - } - ] -} diff --git a/domains/getaura.ai/integrations.json b/domains/getaura.ai/integrations.json index f616fc46..54432f9a 100644 --- a/domains/getaura.ai/integrations.json +++ b/domains/getaura.ai/integrations.json @@ -8,7 +8,8 @@ "authStatus": "unknown", "name": "Aura MCP Server", "slug": "aura-mcp-server", - "type": "mcp" + "type": "mcp", + "url": "https://mcp.auraintelligence.com/mcp" } ] } diff --git a/domains/getdbt.com/integrations.json b/domains/getdbt.com/integrations.json index 8df9bee5..588324f1 100644 --- a/domains/getdbt.com/integrations.json +++ b/domains/getdbt.com/integrations.json @@ -1,7 +1,7 @@ { "description": "dbt is a data transformation and analytics engineering platform for building, running, and governing data projects. Its platform includes administrative APIs, metadata and semantic query interfaces, and AI-assisted tooling around dbt projects.", "discoveredAt": "2026-07-02T20:17:42.201Z", - "domain": "dbt.com", + "domain": "getdbt.com", "summary": "dbt exposes two REST Admin/Cloud APIs (v2 and v3), two GraphQL APIs (Discovery and Semantic Layer), a remote and local MCP server, and the dbt Wizard CLI; auth is via personal access tokens, service account tokens, or MCP OAuth depending on surface.", "surfaces": [ { diff --git a/domains/getmiso.com/integrations.json b/domains/getmiso.com/integrations.json deleted file mode 100644 index 3a7c8600..00000000 --- a/domains/getmiso.com/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "getmiso.com is the website for Miso, a Korean home-services marketplace for cleaning, moving, repairs, rentals, and related household services. Its public pages focus on booking services and partner/customer support rather than developer integrations.", - "discoveredAt": "2026-07-02T23:20:46.445Z", - "domain": "getmiso.com", - "summary": "No public developer integration surface found for getmiso.com: the site appears to be a consumer home-services platform plus an internal backoffice, with no documented public API, GraphQL endpoint, MCP server, or CLI on getmiso.com.", - "surfaces": [] -} diff --git a/domains/getzep.com/integrations.json b/domains/getzep.com/integrations.json index 70dfe10b..6d6ff51f 100644 --- a/domains/getzep.com/integrations.json +++ b/domains/getzep.com/integrations.json @@ -1,7 +1,7 @@ { "description": "Zep is a context engineering and agent memory platform that stores user, thread, and business context in temporal knowledge graphs for AI applications. It also publishes Graphiti, an open-source framework for building temporal knowledge graphs.", "discoveredAt": "2026-07-02T21:08:11.444Z", - "domain": "zep.ai", + "domain": "getzep.com", "summary": "Zep exposes a REST HTTP API at https://api.getzep.com, a zepctl admin CLI, a public documentation MCP server at https://docs-mcp.getzep.com/mcp, an enterprise Memory MCP server with per-project endpoints, and Graphiti MCP server docs for its open-source framework.", "surfaces": [ { diff --git a/domains/gitea.com/integrations.json b/domains/gitea.com/integrations.json index a1430ef0..279edb5a 100644 --- a/domains/gitea.com/integrations.json +++ b/domains/gitea.com/integrations.json @@ -17,6 +17,13 @@ "slug": "gitea-rest-api", "type": "http", "url": "https://gitea.com/api/v1" + }, + { + "authStatus": "required", + "name": "Gitea API", + "slug": "gitea-api", + "type": "http", + "url": "/api/v1" } ] } diff --git a/domains/gitea.io/integrations.json b/domains/gitea.io/integrations.json deleted file mode 100644 index 7415dee7..00000000 --- a/domains/gitea.io/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "Gitea is an open-source self-hosted Git forge for source code hosting and collaboration. It provides repository, issue, organization, package, and related development platform features.", - "discoveredAt": "2026-07-02T21:43:21.590Z", - "domain": "gitea.io", - "summary": "Gitea exposes one documented HTTP API surface: the self-hosted Gitea REST API at `/api/v1`, authenticated by Basic auth, personal access tokens, or SSH-key HTTP signatures; no GraphQL, MCP server, or developer CLI surface was found.", - "surfaces": [ - { - "authStatus": "required", - "name": "Gitea API", - "slug": "gitea-api", - "type": "http", - "url": "/api/v1" - } - ] -} diff --git a/domains/github.com/integrations.json b/domains/github.com/integrations.json index 15b745e3..a9706f1d 100644 --- a/domains/github.com/integrations.json +++ b/domains/github.com/integrations.json @@ -28,6 +28,10 @@ { "identifier": "gh", "registryType": "yum" + }, + { + "identifier": "GitHub.cli", + "registryType": "winget" } ], "slug": "github-cli", @@ -37,6 +41,7 @@ "authStatus": "required", "name": "GitHub REST API", "slug": "github-rest-api", + "spec": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", "type": "http", "url": "https://api.github.com" } diff --git a/domains/goshippo.com/integrations.json b/domains/goshippo.com/integrations.json index f7c59185..0bfbf07d 100644 --- a/domains/goshippo.com/integrations.json +++ b/domains/goshippo.com/integrations.json @@ -1,7 +1,7 @@ { "description": "Shippo provides multi-carrier shipping infrastructure for creating labels, comparing rates, validating addresses, tracking packages, and related shipping workflows. It offers both developer APIs and AI-assistant MCP access built on the same shipping capabilities.", "discoveredAt": "2026-07-02T20:50:20.609Z", - "domain": "shippo.com", + "domain": "goshippo.com", "summary": "Shippo exposes a REST shipping API at https://api.goshippo.com with an OpenAPI spec, plus an MCP offering with a hosted remote server at https://mcp.shippo.com (OAuth) and a local npm-distributed MCP server package authenticated by Shippo API token.", "surfaces": [ { diff --git a/domains/gov.bc.ca/integrations.json b/domains/gov.bc.ca/integrations.json index 75ce932e..9bbf41fc 100644 --- a/domains/gov.bc.ca/integrations.json +++ b/domains/gov.bc.ca/integrations.json @@ -1,4 +1,5 @@ { + "description": "BC Laws is an online library of British Columbia legislation and related legal materials published by the Province of British Columbia. It provides web access and raw XML API access to browse, search, and retrieve legal documents.", "discoveredAt": "2026-07-02T21:44:29.496Z", "domain": "gov.bc.ca", "summary": "gov.bc.ca exposes nine recorded HTTP APIs—BC Data Catalogue, BC Geographical Names, BC Address Geocoder, Geomark, Groundwater Wells/Aquifers/Registry, WorkBC Job Posting, BC Gov News, DriveBC Open511, and BC Route Planner—with most public/no-auth, Geocoder and Route Planner requiring a BC Government API Services Portal API key, GWells requiring bearer auth of unclear acquisition/mechanics, and BC Data Catalogue auth status still unknown.", @@ -67,6 +68,14 @@ "slug": "bc-route-planner-rest-api", "type": "http", "url": "https://router.api.gov.bc.ca/" + }, + { + "authStatus": "none", + "name": "BC Laws API", + "slug": "bc-laws-api", + "spec": "https://api.apis.guru/v2/specs/bclaws.ca/bclaws/1.0.0/openapi.json", + "type": "http", + "url": "https://www.bclaws.ca/civix" } ] } diff --git a/domains/graphite.com/integrations.json b/domains/graphite.com/integrations.json index b6d78f95..6ea0b7ad 100644 --- a/domains/graphite.com/integrations.json +++ b/domains/graphite.com/integrations.json @@ -1,7 +1,7 @@ { "description": "Graphite is a code-review and pull-request workflow product built around stacked pull requests for GitHub repositories. It provides developer tooling for creating, updating, and managing PR stacks from the command line and through AI-assisted workflows.", "discoveredAt": "2026-07-02T20:26:12.785Z", - "domain": "graphite.dev", + "domain": "graphite.com", "summary": "Graphite exposes a developer CLI (`gt`) and a stdio MCP server built into that CLI (`gt mcp`); both use the same Graphite CLI auth token. No public HTTP/REST or GraphQL API documentation was found.", "surfaces": [ { diff --git a/domains/hellosign.com/integrations.json b/domains/hellosign.com/integrations.json deleted file mode 100644 index 4e8648fb..00000000 --- a/domains/hellosign.com/integrations.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "description": "Dropbox Sign is an e-signature platform for sending, signing, and managing digital agreement workflows. Its developer offering lets customers embed signing and automate document-related processes through its API.", - "discoveredAt": "2026-07-02T20:28:39.336Z", - "domain": "hellosign.com", - "summary": "hellosign.com exposes the Dropbox Sign REST API with an official OpenAPI spec plus a documentation MCP server; the API authenticates with either an API key over HTTP Basic or OAuth access tokens.", - "surfaces": [ - { - "authStatus": "required", - "name": "Dropbox Sign API", - "slug": "dropbox-sign-api", - "spec": "https://raw.githubusercontent.com/hellosign/hellosign-openapi/main/openapi.yaml", - "type": "http", - "url": "https://api.hellosign.com/v3" - }, - { - "authStatus": "unknown", - "name": "Dropbox Sign docs MCP server", - "slug": "dropbox-sign-docs-mcp-server", - "type": "mcp", - "url": "https://developers.hellosign.com/_mcp/server" - } - ] -} diff --git a/domains/intercom.com/integrations.json b/domains/intercom.com/integrations.json index eadf07e6..36c782a3 100644 --- a/domains/intercom.com/integrations.json +++ b/domains/intercom.com/integrations.json @@ -1,7 +1,7 @@ { "description": "Intercom is a customer service platform for support teams, with messaging, inbox, help center, ticketing, and AI agent features. It provides APIs and MCP servers for accessing workspace data and Fin capabilities.", "discoveredAt": "2026-07-02T23:30:54.587Z", - "domain": "intercom.io", + "domain": "intercom.com", "summary": "Intercom exposes a REST API at `https://api.intercom.io` plus two MCP surfaces: the general Intercom MCP server at `https://mcp.intercom.com/mcp`/`/sse` and the Fin Agent API MCP server at `https://api.intercom.io/fin/mcp` (including an end-user variant with `{app_id_code}`).", "surfaces": [ { diff --git a/domains/konghq.com/integrations.json b/domains/konghq.com/integrations.json index ee78d812..6b713c57 100644 --- a/domains/konghq.com/integrations.json +++ b/domains/konghq.com/integrations.json @@ -59,6 +59,13 @@ "spec": "https://raw.githubusercontent.com/Kong/developer.konghq.com/main/api-specs/konnect/control-planes-config/v2/openapi.yaml", "type": "http", "url": "https://{region}.api.konghq.com" + }, + { + "authStatus": "required", + "name": "GeoDB Cities API", + "slug": "geodb-cities-api", + "type": "http", + "url": "https://wft-geo-db.p.rapidapi.com/v1" } ] } diff --git a/domains/mashape.com/integrations.json b/domains/mashape.com/integrations.json deleted file mode 100644 index 06c62ec5..00000000 --- a/domains/mashape.com/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "Mashape was an API marketplace brand that is now part of Kong. For this domain, the surfaced developer-facing integration is the GeoDB Cities API for country, region, city, and timezone data.", - "discoveredAt": "2026-07-02T22:02:41.891Z", - "domain": "mashape.com", - "summary": "mashape.com exposes one documented developer integration surface here: the GeoDB Cities REST API, authenticated with a RapidAPI key.", - "surfaces": [ - { - "authStatus": "required", - "name": "GeoDB Cities API", - "slug": "geodb-cities-api", - "type": "http", - "url": "https://wft-geo-db.p.rapidapi.com/v1" - } - ] -} diff --git a/domains/massive.com/integrations.json b/domains/massive.com/integrations.json index ce91cd75..90814cf7 100644 --- a/domains/massive.com/integrations.json +++ b/domains/massive.com/integrations.json @@ -8,6 +8,7 @@ "authStatus": "required", "name": "Massive REST API", "slug": "massive-rest-api", + "spec": "https://api.apis.guru/v2/specs/polygon.io/1.0.0/swagger.json", "type": "http", "url": "https://api.massive.com/v3" }, diff --git a/domains/mdsol.com/integrations.json b/domains/mdsol.com/integrations.json deleted file mode 100644 index 97956b9c..00000000 --- a/domains/mdsol.com/integrations.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "description": "Medidata provides software for clinical trial execution and data management, including the Rave electronic data capture platform. Its products support sponsors, CROs, sites, and related systems in managing clinical study data and operations.", - "discoveredAt": "2026-07-02T23:44:38.699Z", - "domain": "mdsol.com", - "summary": "mdsol.com exposes a documented Medidata Rave Web Services HTTP API on tenant subdomains, with Basic auth and Medidata MAuth signing; Medidata also publishes a Python client package (`rwslib`) for that API.", - "surfaces": [ - { - "authStatus": "required", - "name": "Rave Web Services API", - "slug": "rave-web-services-api", - "type": "http", - "url": "https://{subdomain}.mdsol.com/RaveWebServices" - }, - { - "authStatus": "required", - "command": "python -m rwslib", - "name": "Rave Web Services Python CLI/Library", - "packages": [ - { - "identifier": "rwslib", - "registryType": "pypi", - "runtimeHint": "python" - } - ], - "slug": "rave-web-services-python-cli-library", - "type": "cli" - } - ] -} diff --git a/domains/medidata.com/integrations.json b/domains/medidata.com/integrations.json index cb1fef39..5c632c72 100644 --- a/domains/medidata.com/integrations.json +++ b/domains/medidata.com/integrations.json @@ -15,6 +15,27 @@ "name": "Medidata MCP server", "slug": "medidata-mcp-server", "type": "mcp" + }, + { + "authStatus": "required", + "name": "Rave Web Services API", + "slug": "rave-web-services-api", + "type": "http", + "url": "https://{subdomain}.mdsol.com/RaveWebServices" + }, + { + "authStatus": "required", + "command": "python -m rwslib", + "name": "Rave Web Services Python CLI/Library", + "packages": [ + { + "identifier": "rwslib", + "registryType": "pypi", + "runtimeHint": "python" + } + ], + "slug": "rave-web-services-python-cli-library", + "type": "cli" } ] } diff --git a/domains/monoscope.tech/integrations.json b/domains/monoscope.tech/integrations.json index 17aa8026..f16afaad 100644 --- a/domains/monoscope.tech/integrations.json +++ b/domains/monoscope.tech/integrations.json @@ -1,7 +1,7 @@ { "description": "Monoscope (formerly APItoolkit) is an API observability platform for monitoring API traffic, logs, traces, metrics, anomalies, and monitors. It provides a dashboard, SDKs/OpenTelemetry ingestion, and query tools for developers and AI agents.", "discoveredAt": "2026-07-02T20:02:59.343Z", - "domain": "apitoolkit.io", + "domain": "monoscope.tech", "summary": "Monoscope exposes a bearer-authenticated REST API at `https://api.monoscope.tech`, a hosted MCP server at `https://api.monoscope.tech/api/v1/mcp`, and a `monoscope` CLI authenticated either by browser login or project API key.", "surfaces": [ { diff --git a/domains/motos.net/integrations.json b/domains/motos.net/integrations.json deleted file mode 100644 index 48a796cb..00000000 --- a/domains/motos.net/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "Motos.net is a Spanish marketplace and information site for motorcycles, including used and new listings, dealers, technical information, and news. It appears to target consumers and sellers rather than offering a public developer platform.", - "discoveredAt": "2026-07-02T23:44:54.047Z", - "domain": "motos.net", - "summary": "No public developer integration surfaces were found for motos.net; available pages indicate a consumer motorcycle marketplace site, with no published API, GraphQL endpoint, MCP server, or CLI for external developers on the motos.net domain.", - "surfaces": [] -} diff --git a/domains/nexmo.com/integrations.json b/domains/nexmo.com/integrations.json deleted file mode 100644 index 19716767..00000000 --- a/domains/nexmo.com/integrations.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "discoveredAt": "2026-07-02T22:10:52.523Z", - "domain": "nexmo.com", - "summary": "Nexmo (now Vonage) exposes multiple HTTP APIs for account, applications, voice, messages, SMS, verify, reports, and conversation, authenticated with either your account API key/secret (as Basic auth or API params, depending on API) or an application-signed JWT for newer APIs like Voice, Messages, and Conversation.", - "surfaces": [ - { - "authStatus": "required", - "name": "Nexmo/Vonage Account API", - "slug": "nexmo-vonage-account-api", - "spec": "https://api.apis.guru/v2/specs/nexmo.com/account/1.0.4/openapi.json", - "type": "http", - "url": "https://rest.nexmo.com" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Application API v2", - "slug": "nexmo-vonage-application-api-v2", - "spec": "https://api.apis.guru/v2/specs/nexmo.com/application.v2/2.1.4/openapi.json", - "type": "http", - "url": "https://api.nexmo.com/v2/applications" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Voice API", - "slug": "nexmo-vonage-voice-api", - "spec": "https://api.apis.guru/v2/specs/nexmo.com/voice/1.3.10/openapi.json", - "type": "http", - "url": "https://api.nexmo.com/v1/calls" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Messages API", - "slug": "nexmo-vonage-messages-api", - "type": "http", - "url": "https://api.nexmo.com/v1/messages" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage SMS API", - "slug": "nexmo-vonage-sms-api", - "spec": "https://api.apis.guru/v2/specs/nexmo.com/sms/1.2.0/openapi.json", - "type": "http", - "url": "https://rest.nexmo.com/sms" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Verify API", - "slug": "nexmo-vonage-verify-api", - "type": "http", - "url": "https://api.nexmo.com/verify" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Reports API", - "slug": "nexmo-vonage-reports-api", - "type": "http", - "url": "https://api.nexmo.com" - }, - { - "authStatus": "required", - "name": "Nexmo/Vonage Conversation API", - "slug": "nexmo-vonage-conversation-api", - "spec": "https://api.apis.guru/v2/specs/nexmo.com/conversation/2.0.1/openapi.json", - "type": "http", - "url": "https://api.nexmo.com/v0.1" - } - ] -} diff --git a/domains/nfusionsolutions.biz/integrations.json b/domains/nfusionsolutions.biz/integrations.json deleted file mode 100644 index f1f188ba..00000000 --- a/domains/nfusionsolutions.biz/integrations.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "description": "nFusion Solutions provides software and market-data products for precious-metals and bullion businesses, including real-time spot pricing, pricing automation, charts, and dealer integrations. Its public site describes a bullion business management platform and precious-metals data feeds.", - "discoveredAt": "2026-07-02T22:10:31.719Z", - "domain": "nfusionsolutions.biz", - "summary": "nFusion Solutions exposes one documented HTTP REST surface: the authenticated Market Data API at https://api.nfusionsolutions.biz with an OpenAPI spec; no public GraphQL, MCP, or CLI surfaces were found.", - "surfaces": [ - { - "authStatus": "required", - "name": "nFusion Solutions Market Data API", - "slug": "nfusion-solutions-market-data-api", - "spec": "https://api.nfusionsolutions.biz/swagger/1/swagger.json", - "type": "http", - "url": "https://api.nfusionsolutions.biz" - } - ] -} diff --git a/domains/nfusionsolutions.com/integrations.json b/domains/nfusionsolutions.com/integrations.json index 1ca69e52..c0b7d052 100644 --- a/domains/nfusionsolutions.com/integrations.json +++ b/domains/nfusionsolutions.com/integrations.json @@ -11,6 +11,14 @@ "spec": "https://dashboard.nfusionsolutions.com/swagger/1/swagger.json", "type": "http", "url": "https://dashboard.nfusionsolutions.com" + }, + { + "authStatus": "required", + "name": "nFusion Solutions Market Data API", + "slug": "nfusion-solutions-market-data-api", + "spec": "https://api.nfusionsolutions.biz/swagger/1/swagger.json", + "type": "http", + "url": "https://api.nfusionsolutions.biz" } ] } diff --git a/domains/planetscale.com/integrations.json b/domains/planetscale.com/integrations.json index a74dcad3..a5c2783d 100644 --- a/domains/planetscale.com/integrations.json +++ b/domains/planetscale.com/integrations.json @@ -26,6 +26,15 @@ { "identifier": "planetscale/tap/pscale", "registryType": "homebrew" + }, + { + "identifier": "planetscale/cli", + "registryType": "github", + "runtimeHint": "prebuilt binaries/releases" + }, + { + "identifier": "pscale", + "registryType": "scoop" } ], "slug": "planetscale-cli", diff --git a/domains/polygon.io/integrations.json b/domains/polygon.io/integrations.json deleted file mode 100644 index ed404ec4..00000000 --- a/domains/polygon.io/integrations.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "description": "Polygon.io, branded here as Massive, provides financial market data for stocks, options, futures, indices, forex, crypto, and related datasets. Its products deliver historical, real-time, and bulk-download market data for developers and analysts.", - "discoveredAt": "2026-07-02T22:20:10.668Z", - "domain": "polygon.io", - "summary": "polygon.io (Massive) exposes a REST API, a WebSocket streaming API, an S3-compatible Flat Files endpoint, and a remote MCP server; all require Massive-issued credentials.", - "surfaces": [ - { - "authStatus": "required", - "name": "Massive WebSocket API", - "slug": "massive-websocket-api", - "type": "http", - "url": "wss://socket.massive.com/{asset_class}" - }, - { - "authStatus": "required", - "name": "Massive Flat Files S3 endpoint", - "slug": "massive-flat-files-s3-endpoint", - "type": "http", - "url": "https://files.massive.com" - }, - { - "authStatus": "required", - "name": "Massive Remote MCP Server", - "slug": "massive-remote-mcp-server", - "type": "mcp", - "url": "https://mcp.massive.com/" - }, - { - "authStatus": "required", - "name": "Massive REST API", - "slug": "massive-rest-api", - "spec": "https://api.apis.guru/v2/specs/polygon.io/1.0.0/swagger.json", - "type": "http", - "url": "https://api.massive.com" - } - ] -} diff --git a/domains/prefect.io/integrations.json b/domains/prefect.io/integrations.json index d2c22858..15f026b7 100644 --- a/domains/prefect.io/integrations.json +++ b/domains/prefect.io/integrations.json @@ -36,6 +36,13 @@ "name": "Prefect MCP server (local/self-hosted)", "slug": "prefect-mcp-server-local-self-hosted", "type": "mcp" + }, + { + "authStatus": "unknown", + "name": "Horizon-hosted MCP server endpoint", + "slug": "horizon-hosted-mcp-server-endpoint", + "type": "mcp", + "url": "https://your-server-name.fastmcp.app/mcp" } ] } diff --git a/domains/pscale.dev/integrations.json b/domains/pscale.dev/integrations.json deleted file mode 100644 index 43f7c76d..00000000 --- a/domains/pscale.dev/integrations.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "description": "PlanetScale is a managed database platform for Vitess/MySQL and Postgres. It provides hosted database infrastructure plus management features such as organizations, databases, branches, deploy requests, schema information, and insights.", - "discoveredAt": "2026-07-02T22:20:07.089Z", - "domain": "pscale.dev", - "summary": "PlanetScale exposes a REST API at `https://api.planetscale.com/v1` with an OpenAPI spec, a hosted MCP server at `https://mcp.pscale.dev/mcp/planetscale`, and the `pscale` CLI.", - "surfaces": [ - { - "authStatus": "required", - "name": "PlanetScale API", - "slug": "planetscale-api", - "spec": "https://planetscale.com/docs/openapi.yaml", - "type": "http", - "url": "https://api.planetscale.com/v1" - }, - { - "authStatus": "required", - "name": "PlanetScale MCP server", - "slug": "planetscale-mcp-server", - "type": "mcp", - "url": "https://mcp.pscale.dev/mcp/planetscale" - }, - { - "authStatus": "required", - "command": "pscale", - "name": "pscale CLI", - "packages": [ - { - "identifier": "planetscale/tap/pscale", - "registryType": "homebrew" - }, - { - "identifier": "planetscale/cli", - "registryType": "github", - "runtimeHint": "prebuilt binaries/releases" - }, - { - "identifier": "pscale", - "registryType": "scoop" - } - ], - "slug": "pscale-cli", - "type": "cli" - } - ] -} diff --git a/domains/railway.com/integrations.json b/domains/railway.com/integrations.json index ac3bebb0..11b5c387 100644 --- a/domains/railway.com/integrations.json +++ b/domains/railway.com/integrations.json @@ -1,7 +1,7 @@ { "description": "Railway is a cloud infrastructure and application deployment platform for running apps, databases, volumes, functions, and related services. It provides a web dashboard, CLI, API, and AI-agent tooling for managing projects and deployments.", "discoveredAt": "2026-07-02T20:47:44.466Z", - "domain": "railway.app", + "domain": "railway.com", "summary": "Railway exposes a GraphQL public API, a hosted remote MCP endpoint, a local CLI-backed MCP server, and the `railway` CLI; all require Railway-issued tokens or OAuth-based user auth.", "surfaces": [ { diff --git a/domains/readme.com/integrations.json b/domains/readme.com/integrations.json index a3184f4f..39ffea91 100644 --- a/domains/readme.com/integrations.json +++ b/domains/readme.com/integrations.json @@ -8,6 +8,7 @@ "authStatus": "required", "name": "ReadMe API", "slug": "readme-api", + "spec": "https://api.apis.guru/v2/specs/readme.io/2.0.0/openapi.json", "type": "http", "url": "https://api.readme.com/v2" }, @@ -44,6 +45,19 @@ ], "slug": "readme-cli", "type": "cli" + }, + { + "authStatus": "required", + "name": "ReadMe API v2", + "slug": "readme-api-v2", + "type": "http", + "url": "https://api.readme.com/v2" + }, + { + "authStatus": "unknown", + "name": "ReadMe MCP Server", + "slug": "readme-mcp-server", + "type": "mcp" } ] } diff --git a/domains/readme.io/integrations.json b/domains/readme.io/integrations.json deleted file mode 100644 index 958c8885..00000000 --- a/domains/readme.io/integrations.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "description": "ReadMe is a platform for building and hosting product and API documentation, including API reference, changelogs, guides, and developer experience features. It also offers AI-related documentation features such as MCP access to docs and API specs.", - "discoveredAt": "2026-07-02T22:22:18.914Z", - "domain": "readme.io", - "summary": "ReadMe exposes two documented HTTP API surfaces for project/docs management (legacy v1 with OpenAPI and newer v2) plus documentation for an MCP server; no public GraphQL API or CLI was found.", - "surfaces": [ - { - "authStatus": "required", - "name": "ReadMe API", - "slug": "readme-api", - "spec": "https://api.apis.guru/v2/specs/readme.io/2.0.0/openapi.json", - "type": "http", - "url": "https://dash.readme.io/api/v1" - }, - { - "authStatus": "required", - "name": "ReadMe API v2", - "slug": "readme-api-v2", - "type": "http", - "url": "https://api.readme.com/v2" - }, - { - "authStatus": "unknown", - "name": "ReadMe MCP Server", - "slug": "readme-mcp-server", - "type": "mcp" - } - ] -} diff --git a/domains/rumble.run/integrations.json b/domains/rumble.run/integrations.json deleted file mode 100644 index 049fee46..00000000 --- a/domains/rumble.run/integrations.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "description": "Rumble Cloud is a cloud infrastructure platform with compute, network, storage, Kubernetes, and automation services. The `rumble.run` API branding is deprecated in favor of runZero, but the published spec still documents the older Rumble API surface.", - "discoveredAt": "2026-07-02T22:24:19.800Z", - "domain": "rumble.run", - "summary": "rumble.run exposes one documented HTTP API surface: the deprecated Rumble API at `https://console.rumble.run/api/v1.0`, described by an OpenAPI spec and authenticated with bearer tokens (organization key/export token).", - "surfaces": [ - { - "authStatus": "required", - "name": "Rumble API (deprecated)", - "slug": "rumble-api-deprecated", - "spec": "https://raw.githubusercontent.com/RumbleDiscovery/rumble-api/main/rumble-api.yml", - "type": "http", - "url": "https://console.rumble.run/api/v1.0" - } - ] -} diff --git a/domains/runzero.com/integrations.json b/domains/runzero.com/integrations.json index 9900af6e..68e48cb6 100644 --- a/domains/runzero.com/integrations.json +++ b/domains/runzero.com/integrations.json @@ -17,6 +17,14 @@ "name": "runZero CLI", "slug": "runzero-cli", "type": "cli" + }, + { + "authStatus": "required", + "name": "Rumble API (deprecated)", + "slug": "rumble-api-deprecated", + "spec": "https://raw.githubusercontent.com/RumbleDiscovery/rumble-api/main/rumble-api.yml", + "type": "http", + "url": "https://console.rumble.run/api/v1.0" } ] } diff --git a/domains/sitejabber.com/integrations.json b/domains/sitejabber.com/integrations.json deleted file mode 100644 index 45e4a6ad..00000000 --- a/domains/sitejabber.com/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "Sitejabber/SmartCustomer is a reviews and ratings platform where consumers can research businesses, read and write reviews, and browse categories and resources. It also offers business-facing review collection and management products, but the public pages found here did not document any developer integration interface on sitejabber.com itself.", - "discoveredAt": "2026-07-03T00:01:59.155Z", - "domain": "sitejabber.com", - "summary": "sitejabber.com exposes no documented developer integration surfaces (no public API, GraphQL API, MCP server, or CLI found); the domain mainly hosts consumer and business marketing web pages, while business login is on biz.smartcustomer.com.", - "surfaces": [] -} diff --git a/domains/strivemath.com/integrations.json b/domains/strivemath.com/integrations.json index 5b8e695c..f3a2cacb 100644 --- a/domains/strivemath.com/integrations.json +++ b/domains/strivemath.com/integrations.json @@ -10,6 +10,13 @@ "slug": "strive-editor", "type": "http", "url": "https://code.strivemath.com/ide" + }, + { + "authStatus": "none", + "name": "Strive PDF Generator MCP", + "slug": "strive-pdf-generator-mcp", + "type": "mcp", + "url": "https://mcp.strivemaths.com/mcp" } ] } diff --git a/domains/strivemaths.com/integrations.json b/domains/strivemaths.com/integrations.json deleted file mode 100644 index 09dafb6e..00000000 --- a/domains/strivemaths.com/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "Strive Maths is an education service offering online coding and mathematics classes for children. Its public website focuses on course offerings, teachers, and enrollment information.", - "discoveredAt": "2026-07-02T22:28:58.670Z", - "domain": "strivemaths.com", - "summary": "strivemaths.com exposes a public MCP server at https://mcp.strivemaths.com/mcp; no public HTTP APIs, GraphQL APIs, or CLIs were found.", - "surfaces": [ - { - "authStatus": "none", - "name": "Strive PDF Generator MCP", - "slug": "strive-pdf-generator-mcp", - "type": "mcp", - "url": "https://mcp.strivemaths.com/mcp" - } - ] -} diff --git a/domains/twitter.com/integrations.json b/domains/twitter.com/integrations.json deleted file mode 100644 index e3faf9fc..00000000 --- a/domains/twitter.com/integrations.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "description": "Twitter, now branded as X, is a social networking platform for publishing and consuming short-form posts, media, conversations, and related account and advertising data. Its developer platform provides programmatic access to X content and account capabilities through APIs and AI-tool integrations.", - "discoveredAt": "2026-07-02T21:03:26.582Z", - "domain": "twitter.com", - "summary": "twitter.com exposes two documented HTTP API generations (current v2 and legacy v1.1), plus hosted MCP endpoints for the API and docs, and an official `xurl` CLI used to authenticate and bridge MCP access.", - "surfaces": [ - { - "authStatus": "required", - "name": "Twitter API v2", - "slug": "twitter-api-v2", - "type": "http", - "url": "https://api.twitter.com" - }, - { - "authStatus": "unknown", - "name": "Twitter API v1.1 (legacy)", - "slug": "twitter-api-v1-1-legacy", - "type": "http", - "url": "https://api.twitter.com/1.1" - }, - { - "authStatus": "required", - "name": "Hosted X MCP", - "slug": "hosted-x-mcp", - "type": "mcp", - "url": "https://api.x.com/mcp" - }, - { - "authStatus": "unknown", - "name": "X Docs MCP", - "slug": "x-docs-mcp", - "type": "mcp" - }, - { - "authStatus": "required", - "command": "xurl", - "name": "xurl", - "packages": [ - { - "identifier": "@xdevplatform/xurl", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "xurl", - "type": "cli" - } - ] -} diff --git a/domains/vonage.com/integrations.json b/domains/vonage.com/integrations.json index 289df807..7d745ed1 100644 --- a/domains/vonage.com/integrations.json +++ b/domains/vonage.com/integrations.json @@ -69,6 +69,67 @@ ], "slug": "vonage-cli", "type": "cli" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Account API", + "slug": "nexmo-vonage-account-api", + "spec": "https://api.apis.guru/v2/specs/nexmo.com/account/1.0.4/openapi.json", + "type": "http", + "url": "https://rest.nexmo.com" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Application API v2", + "slug": "nexmo-vonage-application-api-v2", + "spec": "https://api.apis.guru/v2/specs/nexmo.com/application.v2/2.1.4/openapi.json", + "type": "http", + "url": "https://api.nexmo.com/v2/applications" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Voice API", + "slug": "nexmo-vonage-voice-api", + "spec": "https://api.apis.guru/v2/specs/nexmo.com/voice/1.3.10/openapi.json", + "type": "http", + "url": "https://api.nexmo.com/v1/calls" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Messages API", + "slug": "nexmo-vonage-messages-api", + "type": "http", + "url": "https://api.nexmo.com/v1/messages" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage SMS API", + "slug": "nexmo-vonage-sms-api", + "spec": "https://api.apis.guru/v2/specs/nexmo.com/sms/1.2.0/openapi.json", + "type": "http", + "url": "https://rest.nexmo.com/sms" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Verify API", + "slug": "nexmo-vonage-verify-api", + "type": "http", + "url": "https://api.nexmo.com/verify" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Reports API", + "slug": "nexmo-vonage-reports-api", + "type": "http", + "url": "https://api.nexmo.com" + }, + { + "authStatus": "required", + "name": "Nexmo/Vonage Conversation API", + "slug": "nexmo-vonage-conversation-api", + "spec": "https://api.apis.guru/v2/specs/nexmo.com/conversation/2.0.1/openapi.json", + "type": "http", + "url": "https://api.nexmo.com/v0.1" } ] } diff --git a/domains/watchful.li/integrations.json b/domains/watchful.li/integrations.json deleted file mode 100644 index f28eadc8..00000000 --- a/domains/watchful.li/integrations.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "description": "Watchful is a website maintenance platform for agencies and site owners that helps manage WordPress and Joomla sites, including updates, backups, monitoring, logs, and reports.", - "discoveredAt": "2026-07-02T22:42:27.190Z", - "domain": "watchful.li", - "summary": "Watchful exposes one REST API for site-management operations, documented via a Swagger/OpenAPI description and authenticated by either a JWT bearer token or an API key query parameter.", - "surfaces": [ - { - "authStatus": "required", - "name": "Watchful REST API", - "slug": "watchful-rest-api", - "type": "http", - "url": "https://watchful.li/api/v1" - } - ] -} diff --git a/domains/x.com/integrations.json b/domains/x.com/integrations.json index cbc8e60a..e72100c6 100644 --- a/domains/x.com/integrations.json +++ b/domains/x.com/integrations.json @@ -48,6 +48,20 @@ ], "slug": "xurl", "type": "cli" + }, + { + "authStatus": "required", + "name": "Twitter API v2", + "slug": "twitter-api-v2", + "type": "http", + "url": "https://api.twitter.com" + }, + { + "authStatus": "unknown", + "name": "Twitter API v1.1 (legacy)", + "slug": "twitter-api-v1-1-legacy", + "type": "http", + "url": "https://api.twitter.com/1.1" } ] } diff --git a/domains/zoho.com/integrations.json b/domains/zoho.com/integrations.json index d904fdb7..4bb29053 100644 --- a/domains/zoho.com/integrations.json +++ b/domains/zoho.com/integrations.json @@ -38,6 +38,27 @@ ], "slug": "zdk-cli", "type": "cli" + }, + { + "authStatus": "required", + "name": "Zoho Desk API (EU)", + "slug": "zoho-desk-api-eu", + "type": "http", + "url": "https://desk.zoho.eu/api/v1" + }, + { + "authStatus": "required", + "name": "Zoho Projects API (EU)", + "slug": "zoho-projects-api-eu", + "type": "http", + "url": "https://projects.zoho.eu/api/v3/portal/{portalId}" + }, + { + "authStatus": "required", + "name": "Zoho CRM REST API v8", + "slug": "zoho-crm-rest-api-v8", + "type": "http", + "url": "https://www.zohoapis.com/crm/v8/" } ] } diff --git a/domains/zoho.eu/integrations.json b/domains/zoho.eu/integrations.json deleted file mode 100644 index 7f2ae571..00000000 --- a/domains/zoho.eu/integrations.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "description": "Zoho provides a suite of business SaaS applications including help desk, project management, CRM, finance, collaboration, and scheduling tools. The zoho.eu domain serves EU-region product sites and some EU-region application/API hosts.", - "discoveredAt": "2026-07-03T00:20:11.633Z", - "domain": "zoho.eu", - "summary": "zoho.eu exposes documented EU-hosted REST APIs for Zoho Desk and Zoho Projects, both authenticated with Zoho OAuth from the EU API Console; no zoho.eu-hosted GraphQL, MCP connect endpoint, or CLI endpoint could be confirmed from public docs.", - "surfaces": [ - { - "authStatus": "required", - "name": "Zoho Desk API (EU)", - "slug": "zoho-desk-api-eu", - "type": "http", - "url": "https://desk.zoho.eu/api/v1" - }, - { - "authStatus": "required", - "name": "Zoho Projects API (EU)", - "slug": "zoho-projects-api-eu", - "type": "http", - "url": "https://projects.zoho.eu/api/v3/portal/{portalId}" - } - ] -} diff --git a/domains/zoho.in/integrations.json b/domains/zoho.in/integrations.json deleted file mode 100644 index 745c20f7..00000000 --- a/domains/zoho.in/integrations.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "description": "Zoho provides business software including customer relationship management and other productivity applications. The zoho.in domain represents Zoho's regional site and links to the same Zoho CRM developer platform and AI-agent features as its global site.", - "discoveredAt": "2026-07-03T00:18:48.939Z", - "domain": "zoho.in", - "summary": "zoho.in exposes documented Zoho CRM developer surfaces including a REST API, a GraphQL API, and a CLI; both APIs use Zoho OAuth bearer tokens, while the CLI auth mechanics were not confirmed from the scraped docs.", - "surfaces": [ - { - "authStatus": "required", - "name": "Zoho CRM REST API v8", - "slug": "zoho-crm-rest-api-v8", - "type": "http", - "url": "https://www.zohoapis.com/crm/v8/" - }, - { - "authStatus": "required", - "name": "Zoho CRM GraphQL API", - "slug": "zoho-crm-graphql-api", - "spec": "introspection", - "type": "graphql", - "url": "https://www.zohoapis.com/crm/graphql" - }, - { - "authStatus": "unknown", - "command": "zdk", - "name": "ZDK CLI for Zoho CRM", - "packages": [ - { - "identifier": "@zohocrm/zdk-cli", - "registryType": "npm", - "runtimeHint": "node" - } - ], - "slug": "zdk-cli-for-zoho-crm", - "type": "cli" - } - ] -} diff --git a/domains/zoho.sa/integrations.json b/domains/zoho.sa/integrations.json deleted file mode 100644 index 46b366c7..00000000 --- a/domains/zoho.sa/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "Zoho provides business software such as CRM and productivity applications. The `zoho.sa` domain appears to be Zoho's Saudi Arabia regional presence, but no public API, GraphQL, MCP, or CLI integration surface on `zoho.sa` itself could be confirmed from public documentation.", - "discoveredAt": "2026-07-03T00:18:52.905Z", - "domain": "zoho.sa", - "summary": "No developer integration surfaces could be confidently attributed to `zoho.sa` itself. Publicly discoverable Zoho developer docs found were for other Zoho domains and endpoints, while the only `zoho.sa` developer-adjacent host located was an account-gated API console.", - "surfaces": [] -} diff --git a/domains/zohocloud.ca/integrations.json b/domains/zohocloud.ca/integrations.json deleted file mode 100644 index d12dd7f4..00000000 --- a/domains/zohocloud.ca/integrations.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "description": "Zoho provides a suite of business SaaS applications such as CRM, finance, collaboration, and developer tools. The zohocloud.ca domain is Zoho's Canada regional web/account presence tied to its multi-data-center setup.", - "discoveredAt": "2026-07-03T00:19:04.668Z", - "domain": "zohocloud.ca", - "summary": "zohocloud.ca itself appears to be a regional Zoho marketing/account domain, not a distinct developer surface; no public API, GraphQL, MCP endpoint, or CLI hosted on zohocloud.ca was documented from the domain-level materials reviewed.", - "surfaces": [] -} diff --git a/output-last-message b/output-last-message new file mode 100644 index 00000000..8da0bba0 --- /dev/null +++ b/output-last-message @@ -0,0 +1,669 @@ +# follow redirects to canonicalize domains + merge duplicates + +## What changed +- Added `scripts/batch/redirect-canonical.ts`, a redirect probe/CLI that follows `https:///`, checks a second path, rejects same-registrable `www`/apex flips, reject-lists parked targets, rejects deep unrelated final paths, rejects unrelated intermediate-hop chains, and has source exemptions for apex redirects that should not collapse service endpoint domains. +- Wired redirect probing into `scripts/batch/sync-kv.ts`; accepted aliases are written only when the canonical target has a catalog row in the incoming batch or existing tree. +- Changed catalog merging to field-safe alias merges: canonical scalar fields win; surfaces are unioned and compact duplicate display identities collapse; CLI package registries are unioned through `dedupSurfaces`. +- Added `bun run redirect:canonical` as the standalone entry point. +- Applied catalog-backed aliases found by the probe, including `pscale.dev -> planetscale.com`, and removed alias-side catalog records after merging. + +## Files touched +- Code/tests: `package.json`, `scripts/batch/redirect-canonical.ts`, `scripts/batch/redirect-canonical.test.ts`, `scripts/batch/dedup.ts`, `scripts/batch/dedup.test.ts`, `scripts/batch/discovered-catalog.ts`, `scripts/batch/discovered-catalog.test.ts`, `scripts/batch/sync-kv.ts`, `src/lib/domain-aliases.ts`, `src/lib/domain-aliases.test.ts`. +- Catalog data: merged alias records for the applied aliases listed below; alias-side `domains//integrations.json` files were removed and canonical `domains//integrations.json` files were updated. + +## Applied aliases +- ably.io -> ably.com +- ably.net -> ably.com +- adobe.io -> adobe.com +- alpic.live -> alpic.ai +- amentum.space -> amentum.io +- apidocumentation.com -> scalar.com +- auraintel.com -> getaura.ai +- auraintelligence.com -> getaura.ai +- avatars1.githubusercontent.com -> github.com +- bclaws.ca -> gov.bc.ca +- bufferapp.com -> buffer.com +- domotz.app -> domotz.com +- ethoslife.com -> ethos.com +- fastmcp.cloud -> prefect.io +- formapi.io -> docspring.com +- getmiso.com -> miso.kr +- gitea.io -> gitea.com +- hellosign.com -> dropbox.com +- mashape.com -> konghq.com +- mdsol.com -> medidata.com +- motos.net -> coches.net +- nexmo.com -> vonage.com +- nfusionsolutions.biz -> nfusionsolutions.com +- polygon.io -> massive.com +- pscale.dev -> planetscale.com +- readme.io -> readme.com +- rumble.run -> runzero.com +- sitejabber.com -> smartcustomer.com +- strivemaths.com -> strivemath.com +- twitter.com -> x.com +- watchful.li -> watchful.net +- zoho.eu -> zoho.com +- zoho.in -> zoho.com +- zoho.sa -> zoho.com +- zohocloud.ca -> zoho.com + +## Probe report +- Accepted proposals: 89 +- Rejected decisions: 494 +- No-alias decisions: 2595 +- Accepted but not applied because the target catalog record was absent: 54 + +## Accepted proposals +- ably.io -> ably.com (applied) +- ably.net -> ably.com (applied) +- adobe.io -> adobe.com (applied) +- aifortunedoctor.com -> cheongwoldang.com (reported-only) +- alpic.live -> alpic.ai (applied) +- amentum.space -> amentum.io (applied) +- aoncadis.org -> arcticdata.io (reported-only) +- apidocumentation.com -> scalar.com (applied) +- auraintel.com -> getaura.ai (applied) +- auraintelligence.com -> getaura.ai (applied) +- authkit.app -> authkit.com (reported-only) +- avatars1.githubusercontent.com -> github.com (applied) +- b-cdn.net -> bunny.net (reported-only) +- bajajallianzlife.com -> bajajlifeinsurance.com (reported-only) +- bclaws.ca -> gov.bc.ca (applied) +- bhagavadgita.io -> bhagavadgita.com (reported-only) +- bufferapp.com -> buffer.com (applied) +- cambase.io -> mpe.co.com (reported-only) +- cbse.nic.in -> cbse.gov.in (reported-only) +- cenit.io -> rocketplay-australia.com (reported-only) +- charm.sh -> charm.land (reported-only) +- clickmeter.com -> rebrandly.com (reported-only) +- craime.se -> lagenta.se (reported-only) +- credas.co.uk -> credas.com (reported-only) +- cybertaxonomy.eu -> cybertaxonomy.org (reported-only) +- data2crm.com -> syncmatters.com (reported-only) +- directus.io -> directus.com (reported-only) +- domotz.app -> domotz.com (applied) +- enode.io -> enode.com (reported-only) +- ethoslife.com -> ethos.com (applied) +- fastmcp.cloud -> prefect.io (applied) +- formapi.io -> docspring.com (applied) +- frankiefinancial.com -> frankieone.com (reported-only) +- getmiso.com -> miso.kr (applied) +- getpostman.com -> postman.com (reported-only) +- gitea.io -> gitea.com (applied) +- greenhouse.io -> greenhouse.com (reported-only) +- hellosign.com -> dropbox.com (applied) +- highwaysengland.co.uk -> nationalhighways.co.uk (reported-only) +- httpie.org -> httpie.io (reported-only) +- hubapi.com -> hubspot.com (reported-only) +- ijenko.net -> astucestechnologiques.com (reported-only) +- illumidesk.com -> illumichat.com (reported-only) +- inpost-group.com -> inpost.eu (reported-only) +- jasig.org -> apereo.org (reported-only) +- lambdatest.com -> testmuai.com (reported-only) +- lmnr.ai -> laminar.sh (reported-only) +- mashape.com -> konghq.com (applied) +- mcp.run -> turbomcp.ai (reported-only) +- mdsol.com -> medidata.com (applied) +- miguel76.github.io -> miguel76.io (reported-only) +- morth.nic.in -> morth.gov.in (reported-only) +- motos.net -> coches.net (applied) +- nexmo.com -> vonage.com (applied) +- nfusionsolutions.biz -> nfusionsolutions.com (applied) +- ory.sh -> ory.com (reported-only) +- outreach.io -> outreach.ai (reported-only) +- phantauth-chance.now.sh -> phantauth-chance.vercel.app (reported-only) +- phantauth-faker.now.sh -> phantauth-faker.vercel.app (reported-only) +- polygon.io -> massive.com (applied) +- prss.org -> nprdistribution.org (reported-only) +- pscale.dev -> planetscale.com (applied) +- ptv.vic.gov.au -> transport.vic.gov.au (reported-only) +- pubmed.gov -> nih.gov (reported-only) +- raxeltelematics.com -> damoov.com (reported-only) +- readme.io -> readme.com (applied) +- redis.com -> redis.io (reported-only) +- reliancegeneral.co.in -> indusindinsurance.com (reported-only) +- rumble.run -> runzero.com (applied) +- semaphoreci.com -> semaphore.io (reported-only) +- sf.net -> sourceforge.net (reported-only) +- sitejabber.com -> smartcustomer.com (applied) +- sourceforge.io -> sourceforge.net (reported-only) +- stream.io -> garbesi.domains (reported-only) +- strivemaths.com -> strivemath.com (applied) +- telematicssdk.com -> damoov.com (reported-only) +- traktorpool.com -> tractorpool.com (reported-only) +- trapstreet.com -> exponentapparel.com (reported-only) +- trymalcolm.com -> onmarrow.com (reported-only) +- twitter.com -> x.com (applied) +- unavatar.now.sh -> unavatar.vercel.app (reported-only) +- urlbox.io -> urlbox.com (reported-only) +- userdatahub.com -> damoov.com (reported-only) +- watchful.li -> watchful.net (applied) +- youneedabudget.com -> ynab.com (reported-only) +- zoho.eu -> zoho.com (applied) +- zoho.in -> zoho.com (applied) +- zoho.sa -> zoho.com (applied) +- zohocloud.ca -> zoho.com (applied) + +## Rejected decisions +- 2.final: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- 3scale.net -> redhat.com: deep-unrelated-final-path; root landed on non-root path /en/technologies/jboss-middleware/3scale +- abema-tv.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- abhibus-com-s-account.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- accessoticketing.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- account.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- accuweather.com: root-fetch-failed; The operation was aborted. +- adobelogin.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://adobelogin.com/". For more information, pass `verbose: true` in the second argument to fetch() +- afraid.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://afraid.org/". For more information, pass `verbose: true` in the second argument to fetch() +- agco-ats.com: root-fetch-failed; The operation was aborted. +- akqa-emea-starbucks-chatgpt-app.vercel.app -> starbucks.com: root-second-target-mismatch; root landed on starbucks.com, second path landed on akqa-emea-starbucks-chatgpt-app.vercel.app +- alertersystem.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- allapp.net: root-fetch-failed; The operation was aborted. +- alpic.cloud: root-fetch-failed; The operation was aborted. +- amazonaws.com -> amazon.com: source-exempted; source apex redirects, but subdomains under this registrable domain are independent service endpoints +- amazonaws.com.cn -> amazonaws.cn: source-exempted; source apex redirects, but subdomains under this registrable domain are independent service endpoints +- amplify.aws -> amazon.com: deep-unrelated-final-path; root landed on non-root path /amplify/ +- analytics.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- anchore.io -> anchore.com: root-second-target-mismatch; root landed on anchore.com, second path landed on anchore.io +- andaman.gov.in: root-fetch-failed; The operation was aborted. +- apartmentlist.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apddev.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- api.aws: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- api2crm.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apidapp.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apigee.com -> google.com: denylisted-target; root landed on parked/aggregator target cloud.google.com +- apigee.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apinf.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apinf.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- apispot.io -> bouquet-liban.tv: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): anagomez.com.mx, 7m8.org +- appauthenticator.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- applicationinsights.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- arespass.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- armory.io: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://armory.io/". For more information, pass `verbose: true` in the second argument to fetch() +- arunachal.gov.in: root-fetch-failed; The operation was aborted. +- atcloud.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- auth0-extend.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- authenticators.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- authentiq.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- automations.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- aviationdata.systems: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- azure.com -> microsoft.com: deep-unrelated-final-path; root landed on non-root path /en-us +- azurecontainerapps.io -> microsoft.com: deep-unrelated-final-path; root landed on non-root path /en-us +- basecampapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- basic.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bbci.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- beanstream.com -> shift4.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): bambora.com, worldline.com +- beerfinder.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bestsolo401k.app: root-fetch-failed; The operation was aborted. +- bettermg.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bgbm.org -> bo.berlin: root-second-target-mismatch; root landed on bo.berlin, second path landed on bgbm.org +- bharatpetroleum.com: root-fetch-failed; The operation was aborted. +- bih.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bihar.gov.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bikewise.org -> bikeindex.org: deep-unrelated-final-path; root landed on non-root path /info/bikewise +- bitdj.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- blabla.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bluemix.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bluerain.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- bon-coin.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- box.dev -> box.com: deep-unrelated-final-path; root landed on non-root path /guides +- boxoffice.com: root-fetch-failed; certificate has expired +- boxyhq.com -> ory.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): ory.sh +- bpcs-ad-broker.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- braintreepayments.com -> paypal.com: deep-unrelated-final-path; root landed on non-root path /us/braintree +- brands.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- brex.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- brexapis.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- britbox.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- busbud-int.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://busbud-int.com/". For more information, pass `verbose: true` in the second argument to fetch() +- businessreports.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- byautomata.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- campaigns.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ccp.is: root-fetch-failed; The operation was aborted. +- cfdomains.com: root-fetch-failed; The operation was aborted. +- cgbse.nic.in: root-fetch-failed; The operation was aborted. +- channel4.co.uk: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://channel4.co.uk/". For more information, pass `verbose: true` in the second argument to fetch() +- chdservices.gov.in: root-fetch-failed; The operation was aborted. +- chrome.com -> google.com: denylisted-target; root landed on parked/aggregator target www.google.com +- circuit.com: root-fetch-failed; unable to verify the first certificate +- circuitsandbox.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- citrixonline.com: root-fetch-failed; The operation was aborted. +- citycontext.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://citycontext.com/". For more information, pass `verbose: true` in the second argument to fetch() +- clever-cloud.com -> clever.cloud: root-second-target-mismatch; root landed on clever.cloud, second path landed on clever-cloud.com +- climatekuul.com: root-fetch-failed; The operation was aborted. +- cloud-elements.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://cloud-elements.com/". For more information, pass `verbose: true` in the second argument to fetch() +- cloudshell.googleapis.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com +- cmcp.shop: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- code-scan.com: root-fetch-failed; The operation was aborted. +- codefresh.io -> octopus.com: deep-unrelated-final-path; root landed on non-root path /codefresh +- columnapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- commercejs.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- communitygraph.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- companyprofile.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- contacts.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- content.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- contract-p.fit -> contract.fit: deep-unrelated-final-path; root landed on non-root path /tenantless/en/login +- contract.fit: root-fetch-failed; The operation was aborted. +- cowin.gov.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- cpy.re: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://cpy.re/". For more information, pass `verbose: true` in the second argument to fetch() +- crbonfree.com -> crbonlabs.com: deep-unrelated-final-path; root landed on non-root path /crbonfree +- creditkarma.com: root-fetch-failed; The operation was aborted. +- crediwatch.com: root-fetch-failed; unknown certificate verification error +- crossbrowsertesting.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- csnglobal.net: root-fetch-failed; certificate has expired +- ctmers.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- custhelp.com: root-fetch-failed; The operation was aborted. +- datocms-assets.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- details.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- docketai.com -> docket.io: root-second-target-mismatch; root landed on docket.io, second path landed on docketai.com +- dodo.ac -> nookipedia.com: deep-unrelated-final-path; root landed on non-root path /wiki/Main_Page +- dowjones.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- draftkings.com: root-fetch-failed; The operation was aborted. +- dremio.cloud: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- dropboxapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- dvla.gov.uk -> www.gov.uk: deep-unrelated-final-path; root landed on non-root path /government/organisations/driver-and-vehicle-licensing-agency +- dweet.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- easemytrip.com -> easemytrip.us: root-second-target-mismatch; root landed on easemytrip.us, second path landed on easemytrip.com +- easypdfserver.com: root-fetch-failed; certificate has expired +- ecsbr.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- edistrictodisha.gov.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- edreams.com -> edreams.net: root-second-target-mismatch; root landed on edreams.net, second path landed on edreams.com +- einstein-hub.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- elastiq.click: root-fetch-failed; The operation was aborted. +- email-templates.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- email.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- enzoreader.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- eoding.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- eos.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- epfindia.gov.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- esgenterprise.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- events.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- experiancs.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- fields.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- fieldview.com -> climate.com: deep-unrelated-final-path; root landed on non-root path /en-us.html +- finicity.com -> mastercard.com: deep-unrelated-final-path; root landed on non-root path /us/en/business/open-finance.html +- fintual.com -> fintual.cl: root-second-target-mismatch; root landed on fintual.cl, second path landed on fintual.com +- firebrowse.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- firmalyzer.com: root-fetch-failed; The operation was aborted. +- flix.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- forms.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- fos.tui: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- frankiefinancial.io: root-fetch-failed; The operation was aborted. +- freshdesk.com -> freshworks.com: deep-unrelated-final-path; root landed on non-root path /freshdesk/ +- funtranslations.com: root-fetch-failed; The operation was aborted. +- gambitcomm.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://gambitcomm.com/". For more information, pass `verbose: true` in the second argument to fetch() +- gamesparks.com -> amazon.com: deep-unrelated-final-path; root landed on non-root path /gamelift/servers/partners/ +- gamesparks.net -> amazon.com: deep-unrelated-final-path; root landed on non-root path /gamelift/servers/partners/ +- gathrd.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- getambassador.io -> gravitee.io: deep-unrelated-final-path; root landed on non-root path /gravitee-edge-stack-unified-api-visibility-and-governance +- getgo.com: root-fetch-failed; The operation was aborted. +- github.ae: root-fetch-failed; The operation was aborted. +- github.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://github.org/". For more information, pass `verbose: true` in the second argument to fetch() +- globalwinescore.com -> hugedomains.com: denylisted-target; root landed on parked/aggregator target www.hugedomains.com +- gmail.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com +- goog.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- govcon.dev -> makegov.com: root-second-target-mismatch; root landed on makegov.com, second path landed on govcon.dev +- graphql-europe.org: root-fetch-failed; The operation was aborted. +- gravit.io -> coreldraw.com: deep-unrelated-final-path; root landed on non-root path /en/product/go/ +- gteng.co: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- haloapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- healthie.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- height.app: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- hemlak.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- hemnet.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- hetras-certification.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- hetras.com -> shijigroup.com: deep-unrelated-final-path; root landed on non-root path /daylight-pms +- hetzner.cloud -> hetzner.com: deep-unrelated-final-path; root landed on non-root path /cloud +- hillbillysoftware.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- hp.gov.in: root-fetch-failed; The operation was aborted. +- hp.nic.in: root-fetch-failed; The operation was aborted. +- hpbose.org: root-fetch-failed; The operation was aborted. +- hsforms.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- hydramovies.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ib-mcp.rabi.co: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- id4i.de: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- ideal-postcodes.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- idealo.tools: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- idealspot.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- idealspot.gitlab.io -> gitlab.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): projects.gitlab.io +- idtbeyond.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- images.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- inboxroute.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- inpe.br: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- insighttimer-api.net: root-fetch-failed; unknown certificate verification error +- integration.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- intellifi.nl: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://intellifi.nl/". For more information, pass `verbose: true` in the second argument to fetch() +- intros.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- introvoke.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- intsig.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ipapas.co.kr: root-fetch-failed; certificate has expired +- iptwist.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- iqualify.com: root-fetch-failed; The operation was aborted. +- iva-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- iva-entertainment.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- izettle.com -> paypal.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): zettle.com +- javatpoint.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- jebbit.com -> blueconic.com: deep-unrelated-final-path; root landed on non-root path /experiences-by-jebbit +- jeecup.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- jira.com -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/jira +- jobs.search: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- jooble.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://jooble.com/". For more information, pass `verbose: true` in the second argument to fetch() +- jsdelivr.net: root-fetch-failed; The operation was aborted. +- just-eat.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- kakao.com -> kakaocorp.com: deep-unrelated-final-path; root landed on non-root path /page/ +- kaleyra.com -> tatacommunications.com: deep-unrelated-final-path; root landed on non-root path /kaleyra +- kar.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- karnataka.gov.in: root-fetch-failed; The operation was aborted. +- kartikay-dhar.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- komoot.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- koomalooma.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://koomalooma.com/". For more information, pass `verbose: true` in the second argument to fetch() +- kurviger.de -> kurviger.com: root-second-target-mismatch; root landed on kurviger.com, second path landed on kurviger.de +- lacework.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lacework.com/". For more information, pass `verbose: true` in the second argument to fetch() +- lamini.ai: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- landregistry.gov.uk: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://landregistry.gov.uk/". For more information, pass `verbose: true` in the second argument to fetch() +- ldccai.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- letmc.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- lexy.gitbook.io -> gitbook.com: deep-unrelated-final-path; root landed on non-root path /o/50SIPfMR1iqghYK8hlky/s/DAjxE0NLYI3ib2scBvPQ/ +- lgtm.com -> github.blog: deep-unrelated-final-path; root landed on non-root path /news-insights/product-news/the-next-step-for-lgtm-com-github-code-scanning/ +- lifesciences.googleapis.com: root-fetch-failed; The operation was aborted. +- list-manage.com: root-fetch-failed; The operation was aborted. +- list.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- loganalytics.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- logoraisr.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- loket-acc.nl: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- looker.com -> google.com: denylisted-target; root landed on parked/aggregator target cloud.google.com +- lotadata.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- lottechem.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lottechem.com/". For more information, pass `verbose: true` in the second argument to fetch() +- lottechilsung.co.kr: root-fetch-failed; unable to verify the first certificate +- lotteglogis.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lotteglogis.com/". For more information, pass `verbose: true` in the second argument to fetch() +- lottemembers.com: root-fetch-failed; The operation was aborted. +- lottepnd.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- lotteworld.com: root-fetch-failed; The operation was aborted. +- lummianary.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- lumminary.com: root-fetch-failed; The operation was aborted. +- lycorp.co.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- maersk.io: root-fetch-failed; The operation was aborted. +- magento.com: root-fetch-failed; The operation was aborted. +- magick.nu: root-fetch-failed; unable to verify the first certificate +- mailscript.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- maintenance.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- makemytrip.com: root-fetch-failed; The operation was aborted. +- matches.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- mcafee.com: root-fetch-failed; The operation was aborted. +- mcp.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- mediaroom.com -> prnewswire.com: deep-unrelated-final-path; root landed on non-root path /mediaroom/ +- melody.sh: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- meraki.com -> cisco.com: second-fetch-failed; The operation was aborted. +- meraki.io -> cisco.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): devnetcloud.com +- merano.eu -> merano-suedtirol.it: deep-unrelated-final-path; root landed on non-root path /de/meran.html +- messagebird.com -> bird.com: deep-unrelated-final-path; root landed on non-root path /en-us +- messages.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- metadata.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- microsoft.support: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- microsoftonline.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- migrosone.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- milvus.io: root-fetch-failed; The operation was aborted. +- mist-lab.fr: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- modal.run: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- modern-treasury.com: root-fetch-failed; The operation was aborted. +- mon-voyage-pas-cher.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- moonmoonmoonmoon.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- mozilla.com -> firefox.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): mozilla.org +- mp.gov.in: root-fetch-failed; The operation was aborted. +- mppmc.ac.in: root-fetch-failed; The operation was aborted. +- mro.name -> rohrmoser.name: root-second-target-mismatch; root landed on rohrmoser.name, second path landed on mro.name +- myaccount.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- mybluemix.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- mydomain.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- naked.insure: root-fetch-failed; The operation was aborted. +- namsor.com -> namsor.app: root-second-target-mismatch; root landed on namsor.app, second path landed on namsor.com +- nba.com: root-fetch-failed; The operation was aborted. +- nbg.gr: root-fetch-failed; The operation was aborted. +- ndhm.gov.in: root-fetch-failed; The operation was aborted. +- nebl.io: root-fetch-failed; unable to get local issuer certificate +- negd.gov.in: root-fetch-failed; The operation was aborted. +- neilpatelapi.com: root-fetch-failed; The operation was aborted. +- netatmo.com: root-fetch-failed; The operation was aborted. +- netatmo.net: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://netatmo.net/". For more information, pass `verbose: true` in the second argument to fetch() +- netbox.dev -> netboxlabs.com: deep-unrelated-final-path; root landed on non-root path /products/netbox/ +- netboxdemo.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- newsifytrends.com -> newsify.today: deep-unrelated-final-path; root landed on non-root path /english/US +- nextauth.com: root-fetch-failed; The operation was aborted. +- nexumind.com: root-fetch-failed; The operation was aborted. +- nic.at: root-fetch-failed; The operation was aborted. +- nielit.gov.in: root-fetch-failed; The operation was aborted. +- nist.gov: root-fetch-failed; The operation was aborted. +- nitap.ac.in: root-fetch-failed; The operation was aborted. +- nitp.ac.in: root-fetch-failed; The operation was aborted. +- nliblog.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- nlpcloud.io: root-fetch-failed; The operation was aborted. +- noaa.gov: root-fetch-failed; The operation was aborted. +- nomic.ai: root-fetch-failed; The operation was aborted. +- noodleseed.com: root-fetch-failed; The operation was aborted. +- nookipedia.com: root-fetch-failed; The operation was aborted. +- noosh.com: root-fetch-failed; The operation was aborted. +- nordigen.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- notion.so -> notion.com: root-second-target-mismatch; root landed on notion.com, second path landed on notion.so +- npr.org: root-fetch-failed; The operation was aborted. +- nrel.gov: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ntropy.network: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- obp.ch -> openbankingproject.ch: deep-unrelated-final-path; root landed on non-root path /en/ +- oceandrivers.com: root-fetch-failed; certificate has expired +- oktaapplications.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- olutely.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- olx.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- onfido.com -> entrust.com: deep-unrelated-final-path; root landed on non-root path /products/identity-verification +- openchannel.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- opendatanetwork.com: root-fetch-failed; The operation was aborted. +- opendatasoft.com -> huwise.com: deep-unrelated-final-path; root landed on non-root path /en/ +- openfintech.io -> payatlas.com: second-fetch-failed; The operation was aborted. +- openstates.org -> pluralpolicy.com: deep-unrelated-final-path; root landed on non-root path /open/ +- opentargets.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- opentransportdata.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- openuv.io: root-fetch-failed; The operation was aborted. +- opsgenie.com -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/opsgenie/migration +- optimade.org: root-fetch-failed; The operation was aborted. +- opto22.com: root-fetch-failed; The operation was aborted. +- organization.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- osisoft.com -> aveva.com: deep-unrelated-final-path; root landed on non-root path /en/products/aveva-pi-system/ +- oso.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ote-godaddy.com: root-fetch-failed; The operation was aborted. +- otomoto.com: root-fetch-failed; The operation was aborted. +- otomoto.pl: root-fetch-failed; The operation was aborted. +- otseek.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- outcallerai.com: root-fetch-failed; The operation was aborted. +- overstappen.nl: root-fetch-failed; The operation was aborted. +- owler.com: root-fetch-failed; The operation was aborted. +- oxforddictionaries.com -> oup.com: second-fetch-failed; The operation was aborted. +- oysterhr.com: root-fetch-failed; The operation was aborted. +- pagerduty.com: root-fetch-failed; The operation was aborted. +- palomaparties.com: root-fetch-failed; The operation was aborted. +- pandadoc.com: root-fetch-failed; The operation was aborted. +- pandascore.co: root-fetch-failed; The operation was aborted. +- pandorabots.com: root-fetch-failed; The operation was aborted. +- pane.money: root-fetch-failed; The operation was aborted. +- papayaglobal.com: root-fetch-failed; The operation was aborted. +- paperspace.com: root-fetch-failed; The operation was aborted. +- papinet.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- paragon.com: root-fetch-failed; The operation was aborted. +- parivahan.co.in: root-fetch-failed; The operation was aborted. +- parliament.uk: root-fetch-failed; The operation was aborted. +- partnerwebsite.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- passage.id: root-fetch-failed; The operation was aborted. +- passwordutility.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- patientview.org: root-fetch-failed; unknown certificate verification error +- pay1.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- paypi.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- payrun.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- pcraig3.ca: root-fetch-failed; The operation was aborted. +- peel-ci.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- peoplefinderspro.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- peoplegeneratorapi.live: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- petirico.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- phantauth.cf: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- phantauth.net: root-fetch-failed; certificate has expired +- phone.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- pinelabsonline.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- pixelesq.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- playdigital.com.ar: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- policynote.com: root-fetch-failed; The operation was aborted. +- poly.googleapis.com: root-fetch-failed; The operation was aborted. +- predibase.com -> rubrik.com: deep-unrelated-final-path; root landed on non-root path /products/rubrik-agent-cloud +- presalytics.io: root-fetch-failed; The operation was aborted. +- pressassociation.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- products.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- profile.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- prompts.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- proximitybeacon.googleapis.com: root-fetch-failed; The operation was aborted. +- punjabgovt.gov.in: root-fetch-failed; The operation was aborted. +- pythonboto.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- qstash.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- quarantine.country: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- quattroshapes.com: root-fetch-failed; The operation was aborted. +- quicksold.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- quotes.rest: root-fetch-failed; The operation was aborted. +- railz.ai -> fisglobal.com: deep-unrelated-final-path; root landed on non-root path /products/accounting-data-as-a-service +- raw.githubusercontent.com -> github.com: root-second-target-mismatch; root landed on github.com, second path landed on raw.githubusercontent.com +- rawgit.com -> github.com: deep-unrelated-final-path; root landed on non-root path /rgrove/rawgit +- rbi.digital: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- rbictg.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- receptive.io -> pendo.io: deep-unrelated-final-path; root landed on non-root path /app/ +- record.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- recorrido.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://recorrido.com/". For more information, pass `verbose: true` in the second argument to fetch() +- redbus.com: root-fetch-failed; The operation was aborted. +- redeal.io: root-fetch-failed; The operation was aborted. +- redeal.se: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- refinitiv.com: root-fetch-failed; certificate has expired +- reports.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- researchsolutions.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- revenueodisha.gov.in: root-fetch-failed; The operation was aborted. +- reverb-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ritc.io -> book.gb.net: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): 1481.cn.com +- runorion.com -> bygravity.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): gravity.foundation +- rw.rw -> railsware.com: root-second-target-mismatch; root landed on railsware.com, second path landed on rw.rw +- seatpin.com: root-fetch-failed; The operation was aborted. +- segment.com -> twilio.com: deep-unrelated-final-path; root landed on non-root path /en-us/segment +- segments.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- sendgrid.com -> twilio.com: deep-unrelated-final-path; root landed on non-root path /en-us/sendgrid +- sftapi.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://sftapi.com/". For more information, pass `verbose: true` in the second argument to fetch() +- sharepoint.com -> microsoft.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): office.com +- sharing.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- sheerseo.com: root-fetch-failed; unable to verify the first certificate +- sierra.chat -> sierra.ai: root-second-target-mismatch; root landed on sierra.ai, second path landed on sierra.chat +- sketchup.com -> trimble.com: deep-unrelated-final-path; root landed on non-root path /en +- slideroom.com: root-fetch-failed; The operation was aborted. +- smart-me.com: root-fetch-failed; The operation was aborted. +- sms77.io -> seven.io: deep-unrelated-final-path; root landed on non-root path /en +- sng.link: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- solarvps.com -> hosting.com: deep-unrelated-final-path; root landed on non-root path /en-gb/hosting/vps-hosting/unmanaged/linux/ +- sonicapply.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- sparkpost.com -> bird.com: deep-unrelated-final-path; root landed on non-root path /en-us/products/email +- spdx.org -> spdx.dev: root-second-target-mismatch; root landed on spdx.dev, second path landed on spdx.org +- squarecdn.com -> squareup.com: deep-unrelated-final-path; root landed on non-root path /us/en +- staging-ecotaco.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- stainlessapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- staircase.ai -> gainsight.com: deep-unrelated-final-path; root landed on non-root path /staircase-ai/ +- staticaly.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- statuspage.io -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/statuspage +- stream-io-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- stromdao.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- stubhub.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- stytch.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- subscriber.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- swaggerhub.com -> swagger.io: deep-unrelated-final-path; root landed on non-root path /product/ +- sweetspotgov.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- symanto.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- synq.fm: root-fetch-failed; The operation was aborted. +- tamg.cloud: root-fetch-failed; The operation was aborted. +- targetvalidation.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- tasks.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- taxamo.com -> vertexinc.com: root-second-target-mismatch; root landed on vertexinc.com, second path landed on taxamo.com +- taxrates.io -> zieweh.com: deep-unrelated-final-path; root landed on non-root path /fk +- tbse.in: root-fetch-failed; The operation was aborted. +- tempuri.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://tempuri.org/". For more information, pass `verbose: true` in the second argument to fetch() +- terraform.io -> hashicorp.com: deep-unrelated-final-path; root landed on non-root path /terraform +- testfire.net: root-fetch-failed; The operation was aborted. +- text2data.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- theysaidso.com: root-fetch-failed; The operation was aborted. +- threatjammer.com -> domino4dpoint.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): dominoasik.it.com +- tickets.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- tickettailor.ai: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- timetune.center: root-fetch-failed; unable to get local issuer certificate +- tldraw.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- tn.nic.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- trademeai.co.nz: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- tripura.gov.in: root-fetch-failed; The operation was aborted. +- trivago.com: root-fetch-failed; The operation was aborted. +- tubi.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- turkishairlines.com: root-fetch-failed; The operation was aborted. +- turkishtechlab.com -> turkishtechnology.com: root-second-target-mismatch; root landed on turkishtechnology.com, second path landed on turkishtechlab.com +- twinehealth.com -> healthapp.google: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): fitbit.com, fitbit.google +- tyk.com: root-fetch-failed; The operation was aborted. +- ubersuggest.com -> neilpatel.com: deep-unrelated-final-path; root landed on non-root path /ubersuggest/ +- uiic.co.in: root-fetch-failed; The operation was aborted. +- uinames.com: root-fetch-failed; certificate has expired +- uk.gov.in: root-fetch-failed; The operation was aborted. +- updatron.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- upresults.nic.in: root-fetch-failed; The operation was aborted. +- uscann.net: root-fetch-failed; The operation was aborted. +- user.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- vault.googleapis.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com +- vectara.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- venturu.com: root-fetch-failed; The operation was aborted. +- versioneye.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://versioneye.com/". For more information, pass `verbose: true` in the second argument to fetch() +- vestorly.com -> fmgsuite.com: root-second-target-mismatch; root landed on fmgsuite.com, second path landed on vestorly.com +- victorops.com -> splunk.com: deep-unrelated-final-path; root landed on non-root path /en_us/about-splunk/acquisitions/splunk-on-call.html +- villagechief.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- vppup.in: root-fetch-failed; The operation was aborted. +- vsct.fr: root-fetch-failed; The operation was aborted. +- walmartlabs.com: root-fetch-failed; The operation was aborted. +- website-files.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- webtask.io: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://webtask.io/". For more information, pass `verbose: true` in the second argument to fetch() +- wellknown.ai -> atom.com: deep-unrelated-final-path; root landed on non-root path /lpd/name/WellKnown.ai +- wepromise.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- wesang.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- whapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- windows.net: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://windows.net/". For more information, pass `verbose: true` in the second argument to fetch() +- woopra.com: root-fetch-failed; The operation was aborted. +- worldtimeapi.org: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- wso2apistore.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- ww.com -> weightwatchers.com: deep-unrelated-final-path; root landed on non-root path /us +- xtrf.eu -> xtm.ai: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): xtm.cloud +- yahooapis.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- yardimcp.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- yelpcdn.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- your-website.com: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() +- zappiti.com -> hugedomains.com: denylisted-target; root landed on parked/aggregator target www.hugedomains.com +- zappy.app -> zapier.com: deep-unrelated-final-path; root landed on non-root path /zappy +- zillowgroup.com -> zillow.com: deep-unrelated-final-path; root landed on non-root path /news/ +- zohomcp.com -> zoho.com: deep-unrelated-final-path; root landed on non-root path /mcp/ +- zohomcp.com.au: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://zohomcp.com.au/". For more information, pass `verbose: true` in the second argument to fetch() +- zohomcp.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? +- zuplo.work: root-fetch-failed; Unable to connect. Is the computer able to access the url? + +## Live probe evidence +- pscale.dev -> planetscale.com + - / final: https://planetscale.com/ + - / chain: 307 https://pscale.dev/ -> https://planetscale.com/ | 200 https://planetscale.com/ + - second final: https://planetscale.com/ + - second chain: 307 https://pscale.dev/.well-known/integrationsdotsh-redirect-probe -> https://planetscale.com/ | 200 https://planetscale.com/ + +## Negative guardrail evidence +- apispot.io -> bouquet-liban.tv: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): anagomez.com.mx, 7m8.org + - / final: https://bouquet-liban.tv/ +- Unit tests also cover clean apex redirect acceptance, root-only redirect rejection, denylisted target rejection, and same-registrable www/apex no-alias behavior with mocked fetch responses. + +## PlanetScale merge verification +- `canonicalDomain("pscale.dev") === "planetscale.com"`. +- Catalog files: pscale.dev=missing, planetscale.com=present. +- PlanetScale MCP URLs: https://mcp.pscale.dev/mcp/planetscale, https://mcp.pscale.dev/mcp/planetscale-insights-only. +- PlanetScale CLI packages: homebrew:planetscale/tap/pscale, github:planetscale/cli:prebuilt binaries/releases, scoop:pscale. + +## Verification +- `bun test`: passed, 47 tests, 123 assertions. +- `bun run build`: passed; build completed successfully after `bun run normalize`. +- `bun run validate:batch`: still fails on pre-existing catalog-wide `catalogMissingLocator` and unrelated duplicate-surface rows; alias-merge duplicate offenders introduced during development were fixed before build. + +## Deliberately left out +- Did not apply accepted redirect proposals whose target domain has no catalog record, matching the existing alias convention that every alias points at a canonical catalog row. +- Did not canonicalize `amazonaws.com` or `amazonaws.com.cn`; they are source-exempted because their apex redirects but subdomains under those registrable domains are independent service endpoints. diff --git a/package.json b/package.json index 4ede9ce4..c409c302 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "normalize": "bun scripts/normalize.ts", "extract-tools": "bun scripts/extract-tools.ts", "sync:discovered": "bun scripts/batch/sync-kv.ts", + "redirect:canonical": "bun scripts/batch/redirect-canonical.ts --all", "validate-favicons": "bun scripts/validate-favicons.ts", "validate:batch": "bun scripts/batch/validate-results.ts", "dev": "bun run normalize && astro dev", diff --git a/scripts/batch/dedup.test.ts b/scripts/batch/dedup.test.ts index c7414289..4d982fb5 100644 --- a/scripts/batch/dedup.test.ts +++ b/scripts/batch/dedup.test.ts @@ -94,4 +94,37 @@ describe("dedupSurfaces", () => { }; expect(dedupSurfaces(raw).surfaces).toHaveLength(1); }); + + test("unions CLI package registries when duplicate CLI surfaces collapse", () => { + const raw = { + surfaces: [ + { + type: "cli", + name: "pscale CLI", + command: "pscale", + packages: [{ registryType: "homebrew", identifier: "planetscale/tap/pscale" }], + auth: { status: "required" }, + }, + { + type: "cli", + name: "pscale CLI", + command: "pscale", + packages: [ + { registryType: "homebrew", identifier: "planetscale/tap/pscale" }, + { registryType: "github", identifier: "planetscale/cli", runtimeHint: "prebuilt binaries/releases" }, + { registryType: "scoop", identifier: "pscale" }, + ], + auth: { status: "required" }, + }, + ], + }; + + const merged = dedupSurfaces(raw); + expect(merged.surfaces).toHaveLength(1); + expect(merged.surfaces[0]!.packages).toEqual([ + { registryType: "homebrew", identifier: "planetscale/tap/pscale" }, + { registryType: "github", identifier: "planetscale/cli", runtimeHint: "prebuilt binaries/releases" }, + { registryType: "scoop", identifier: "pscale" }, + ]); + }); }); diff --git a/scripts/batch/dedup.ts b/scripts/batch/dedup.ts index 626ee291..fa24ec34 100644 --- a/scripts/batch/dedup.ts +++ b/scripts/batch/dedup.ts @@ -12,7 +12,7 @@ type SurfaceLike = JsonObject & { spec?: string | null; specAlternates?: string[] | null; command?: string | null; - packages?: Array<{ identifier?: string | null }>; + packages?: Array<{ registryType?: string | null; identifier?: string | null; runtimeHint?: string | null }>; }; type CredentialLike = JsonObject & { @@ -122,6 +122,30 @@ function unionAuthEntries(a: unknown[] | undefined, b: unknown[] | undefined): u return out; } +function packageKey(pkg: { registryType?: string | null; identifier?: string | null; runtimeHint?: string | null }): string { + return [ + normalizeLocator(pkg.registryType), + normalizeLocator(pkg.identifier), + normalizeLocator(pkg.runtimeHint), + ].join("|"); +} + +function unionPackages( + a: SurfaceLike["packages"] | undefined, + b: SurfaceLike["packages"] | undefined, +): SurfaceLike["packages"] | undefined { + const out: NonNullable = []; + const seen = new Set(); + for (const pkg of [...(a ?? []), ...(b ?? [])]) { + if (!pkg?.identifier) continue; + const key = packageKey(pkg); + if (seen.has(key)) continue; + seen.add(key); + out.push(pkg); + } + return out.length ? out : undefined; +} + function mergeSurface(kept: SurfaceLike, dropped: SurfaceLike): SurfaceLike { const keep = shouldPrefer(dropped, kept) ? dropped : kept; const other = keep === kept ? dropped : kept; @@ -145,6 +169,11 @@ function mergeSurface(kept: SurfaceLike, dropped: SurfaceLike): SurfaceLike { } if (!merged.docs && other.docs) merged.docs = other.docs; if (!merged.notes && other.notes) merged.notes = other.notes; + if (merged.type === "cli") { + const packages = unionPackages(keep.packages, other.packages); + if (packages) merged.packages = packages; + else delete merged.packages; + } if (keep.auth?.status === "required" || other.auth?.status === "required") { const keepEntries = keep.auth?.status === "required" ? keep.auth.entries : []; const otherEntries = other.auth?.status === "required" ? other.auth.entries : []; diff --git a/scripts/batch/discovered-catalog.test.ts b/scripts/batch/discovered-catalog.test.ts index 681128fe..dfde0472 100644 --- a/scripts/batch/discovered-catalog.test.ts +++ b/scripts/batch/discovered-catalog.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + aliasMapWith, catalogDomainFromLooseStored, catalogDomainFromStored, mergeCatalogs, @@ -27,6 +28,96 @@ const domain = (domainName: string, discoveredAt: string, summary = `${domainNam ], }); +const splitPlanetScaleAlias: CatalogDomain = { + description: "PlanetScale is a managed database platform for Vitess/MySQL and Postgres. It provides hosted database infrastructure plus management features such as organizations, databases, branches, deploy requests, schema information, and insights.", + discoveredAt: "2026-07-02T22:20:07.089Z", + domain: "pscale.dev", + summary: "PlanetScale exposes a REST API at `https://api.planetscale.com/v1` with an OpenAPI spec, a hosted MCP server at `https://mcp.pscale.dev/mcp/planetscale`, and the `pscale` CLI.", + surfaces: [ + { + authStatus: "required", + name: "PlanetScale API", + slug: "planetscale-api", + spec: "https://planetscale.com/docs/openapi.yaml", + type: "http", + url: "https://api.planetscale.com/v1", + }, + { + authStatus: "required", + name: "PlanetScale MCP server", + slug: "planetscale-mcp-server", + type: "mcp", + url: "https://mcp.pscale.dev/mcp/planetscale", + }, + { + authStatus: "required", + command: "pscale", + name: "pscale CLI", + packages: [ + { + identifier: "planetscale/tap/pscale", + registryType: "homebrew", + }, + { + identifier: "planetscale/cli", + registryType: "github", + runtimeHint: "prebuilt binaries/releases", + }, + { + identifier: "pscale", + registryType: "scoop", + }, + ], + slug: "pscale-cli", + type: "cli", + }, + ], +}; + +const splitPlanetScaleCanonical: CatalogDomain = { + description: "PlanetScale is a database platform for managing MySQL/Vitess and Postgres databases, branches, schema workflows, and related organization resources. Its APIs and tools let developers automate database administration, access metadata and Insights, and integrate PlanetScale into scripts and applications.", + discoveredAt: "2026-07-03T12:59:49.162Z", + domain: "planetscale.com", + summary: "PlanetScale exposes a REST API at https://api.planetscale.com/v1 with an OpenAPI spec, two hosted MCP servers, and the `pscale` CLI.", + surfaces: [ + { + authStatus: "required", + name: "PlanetScale MCP server", + slug: "planetscale-mcp-server", + type: "mcp", + url: "https://mcp.pscale.dev/mcp/planetscale", + }, + { + authStatus: "required", + name: "PlanetScale insights-only MCP server", + slug: "planetscale-insights-only-mcp-server", + type: "mcp", + url: "https://mcp.pscale.dev/mcp/planetscale-insights-only", + }, + { + authStatus: "required", + command: "pscale", + name: "pscale CLI", + packages: [ + { + identifier: "planetscale/tap/pscale", + registryType: "homebrew", + }, + ], + slug: "planetscale-cli", + type: "cli", + }, + { + authStatus: "required", + name: "PlanetScale API", + slug: "planetscale-api", + spec: "https://planetscale.com/docs/openapi.yaml", + type: "http", + url: "https://api.planetscale.com/v1", + }, + ], +}; + describe("mergeCatalogs", () => { test("adds new domains, updates older existing domains, preserves existing-only domains, and keeps newer existing rows", () => { const existing: Catalog = { @@ -56,7 +147,7 @@ describe("mergeCatalogs", () => { expect(merged.catalog.domains.find((row) => row.domain === "existing-only.com")).toBeDefined(); }); - test("merges aliases by canonical domain with newest row winning", () => { + test("merges aliases by canonical domain with canonical scalars winning", () => { const existing: Catalog = { domains: [domain("zoom.us", "2026-07-01T00:00:00.000Z", "alias")] }; const incoming = [domain("zoom.com", "2026-07-02T00:00:00.000Z", "canonical")]; @@ -68,6 +159,30 @@ describe("mergeCatalogs", () => { expect(merged.catalog.domains[0]?.summary).toBe("canonical"); }); + test("merges aliased PlanetScale records without dropping split surfaces or packages", () => { + const merged = mergeCatalogs( + { domains: [splitPlanetScaleCanonical, splitPlanetScaleAlias] }, + [], + { aliases: aliasMapWith({ "pscale.dev": "planetscale.com" }) }, + ); + + expect(merged.catalog.domains).toHaveLength(1); + const row = merged.catalog.domains[0]!; + expect(row.domain).toBe("planetscale.com"); + expect(row.summary).toBe(splitPlanetScaleCanonical.summary); + expect(row.surfaces.filter((surface) => surface.type === "mcp").map((surface) => surface.url)).toEqual([ + "https://mcp.pscale.dev/mcp/planetscale", + "https://mcp.pscale.dev/mcp/planetscale-insights-only", + ]); + const cli = row.surfaces.find((surface) => surface.type === "cli" && surface.command === "pscale"); + expect(cli?.slug).toBe("planetscale-cli"); + expect(cli?.packages?.map((pkg) => `${pkg.registryType}:${pkg.identifier}${pkg.runtimeHint ? `:${pkg.runtimeHint}` : ""}`)).toEqual([ + "homebrew:planetscale/tap/pscale", + "github:planetscale/cli:prebuilt binaries/releases", + "scoop:pscale", + ]); + }); + test("writes and reads one integrations.json file per canonical domain", () => { const dir = mkdtempSync(join(tmpdir(), "catalog-tree-")); try { @@ -77,7 +192,7 @@ describe("mergeCatalogs", () => { ]); expect(written).toEqual({ written: 2, changed: 2, skipped: [] }); - expect(readDomainCatalogTree(dir).domains.map((row) => row.domain)).toEqual(["brand-new.com", "zoom.us"]); + expect(readDomainCatalogTree(dir).domains.map((row) => row.domain)).toEqual(["brand-new.com", "zoom.com"]); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/scripts/batch/discovered-catalog.ts b/scripts/batch/discovered-catalog.ts index 960dca52..4fb8f26f 100644 --- a/scripts/batch/discovered-catalog.ts +++ b/scripts/batch/discovered-catalog.ts @@ -3,7 +3,8 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseDomain } from "tldts"; import type { AuthStatus, StoredDiscovery, Surface } from "../../src/lib/discovery-schema.ts"; -import { canonicalDomain } from "../../src/lib/domain-aliases.ts"; +import { canonicalDomain, DOMAIN_ALIASES } from "../../src/lib/domain-aliases.ts"; +import { dedupSurfaces, normalizeLocator, surfaceDedupKey } from "./dedup.ts"; export const ROOT = fileURLToPath(new URL("../..", import.meta.url)).replace(/\/$/, ""); export const DEFAULT_DOMAIN_CATALOG_DIR = join(ROOT, "domains"); @@ -49,6 +50,10 @@ export type CatalogMergeResult = { changes: Array<{ kind: "new" | "updated"; domain: string; previousDiscoveredAt?: string; nextDiscoveredAt?: string }>; }; +export type CatalogDomainOptions = { + aliases?: Record; +}; + type LooseRecord = Record; function isRecord(value: unknown): value is LooseRecord { @@ -70,13 +75,22 @@ export function stableJson(value: unknown): string { return `${JSON.stringify(sortJsonValue(value), null, 2)}\n`; } -export function catalogDomainKey(input: string | undefined): string | null { +function canonicalDomainWithAliases(domain: string, options?: CatalogDomainOptions): string { + const normalized = domain.toLowerCase().trim(); + return options?.aliases?.[normalized] ?? canonicalDomain(normalized); +} + +export function aliasMapWith(overrides: Record = {}): Record { + return { ...DOMAIN_ALIASES, ...overrides }; +} + +export function catalogDomainKey(input: string | undefined, options?: CatalogDomainOptions): string | null { const domain = input?.trim().toLowerCase().replace(/\.$/, ""); if (!domain || domain.startsWith("__")) return null; if (!/^[a-z0-9.-]+$/.test(domain) || !domain.includes(".")) return null; const info = parseDomain(`https://${domain}`, { allowPrivateDomains: true }); if (info.isIp || !info.domain || !(info.isIcann || info.isPrivate)) return null; - const canonical = canonicalDomain(domain); + const canonical = canonicalDomainWithAliases(domain, options); const canonicalInfo = parseDomain(`https://${canonical}`, { allowPrivateDomains: true }); if (canonicalInfo.isIp || !canonicalInfo.domain || !(canonicalInfo.isIcann || canonicalInfo.isPrivate)) return null; return canonical; @@ -181,6 +195,126 @@ function discoveredTime(domain: CatalogDomain): number { return Number.isFinite(time) ? time : 0; } +function catalogPackageKey(pkg: CatalogPackage): string { + return [ + normalizeLocator(pkg.registryType), + normalizeLocator(pkg.identifier), + normalizeLocator(pkg.runtimeHint), + ].join("|"); +} + +function mergeCatalogPackages(primary: CatalogPackage[] | undefined, secondary: CatalogPackage[] | undefined): CatalogPackage[] | undefined { + const out: CatalogPackage[] = []; + const seen = new Set(); + for (const pkg of [...(primary ?? []), ...(secondary ?? [])]) { + const key = catalogPackageKey(pkg); + if (seen.has(key)) continue; + seen.add(key); + out.push(pkg); + } + return out.length ? out : undefined; +} + +function toDedupSurface(surface: CatalogSurface): Record { + return { + ...surface, + auth: { status: surface.authStatus }, + }; +} + +function fromDedupSurface(surface: Record): CatalogSurface { + const auth = isRecord(surface.auth) ? authStatusValue(surface.auth.status) : authStatusValue(surface.authStatus); + const out: CatalogSurface = { + slug: stringValue(surface.slug) ?? stringValue(surface.name) ?? "surface", + name: stringValue(surface.name) ?? stringValue(surface.slug) ?? "Surface", + type: surface.type as Surface["type"], + authStatus: auth, + }; + const url = stringValue(surface.url); + const spec = stringValue(surface.spec); + const command = stringValue(surface.command); + if (url) out.url = url; + if (spec) out.spec = spec; + if (out.type === "cli") { + if (command) out.command = command; + const packages = compactPackages(surface.packages); + if (packages) out.packages = packages; + } + return out; +} + +function catalogSurfaceIdentityKey(surface: CatalogSurface): string { + return `${surface.type}|${normalizeLocator(surface.name)}`; +} + +function mergeCatalogSurfaceRows(primary: CatalogSurface, secondary: CatalogSurface): CatalogSurface { + const merged: CatalogSurface = { ...secondary, ...primary }; + const packages = mergeCatalogPackages(primary.packages, secondary.packages); + if (packages) merged.packages = packages; + else delete merged.packages; + return merged; +} + +function collapseCatalogSurfaceIdentities(surfaces: CatalogSurface[]): CatalogSurface[] { + const out: CatalogSurface[] = []; + const byIdentity = new Map(); + for (const surface of surfaces) { + const key = catalogSurfaceIdentityKey(surface); + const existingIndex = byIdentity.get(key); + if (existingIndex === undefined) { + byIdentity.set(key, out.length); + out.push(surface); + continue; + } + out[existingIndex] = mergeCatalogSurfaceRows(out[existingIndex]!, surface); + } + return out; +} + +function mergeCatalogSurfaces(primary: CatalogDomain, secondary: CatalogDomain): CatalogSurface[] { + const primarySurfaces = primary.surfaces.map(toDedupSurface); + const primaryByKey = new Map(primarySurfaces.map((surface) => [surfaceDedupKey(surface), surface])); + const raw = { + domain: primary.domain, + surfaces: [...primarySurfaces, ...secondary.surfaces.map(toDedupSurface)], + }; + const deduped = dedupSurfaces(raw).surfaces as Record[]; + + const merged = deduped.map((surface) => { + const primarySurface = primaryByKey.get(surfaceDedupKey(surface)); + if (!primarySurface) return fromDedupSurface(surface); + const merged: Record = { ...surface, ...primarySurface }; + const packages = mergeCatalogPackages(compactPackages(primarySurface.packages), compactPackages(surface.packages)); + if (packages) merged.packages = packages; + else delete merged.packages; + return fromDedupSurface(merged); + }); + return collapseCatalogSurfaceIdentities(merged); +} + +function isCanonicalRow(domain: CatalogDomain, key: string): boolean { + return domain.domain.toLowerCase() === key; +} + +export function mergeCatalogDomainRows( + a: CatalogDomain, + b: CatalogDomain, + key = catalogDomainKey(a.domain) ?? catalogDomainKey(b.domain) ?? a.domain.toLowerCase(), +): CatalogDomain { + const aCanonical = isCanonicalRow(a, key); + const bCanonical = isCanonicalRow(b, key); + const primary = aCanonical !== bCanonical + ? aCanonical ? a : b + : discoveredTime(b) > discoveredTime(a) ? b : a; + const secondary = primary === a ? b : a; + return { + ...secondary, + ...primary, + domain: key, + surfaces: mergeCatalogSurfaces(primary, secondary), + }; +} + function domainFilePath(root: string, canonical: string): string { return join(root, canonical, "integrations.json"); } @@ -198,13 +332,13 @@ export function readDomainCatalogFile(path: string): CatalogDomain { return JSON.parse(readFileSync(path, "utf8")) as CatalogDomain; } -export function readDomainCatalogTree(root = DEFAULT_DOMAIN_CATALOG_DIR): Catalog { +export function readDomainCatalogTree(root = DEFAULT_DOMAIN_CATALOG_DIR, options?: CatalogDomainOptions): Catalog { const domains = listDomainCatalogFiles(root) .map(readDomainCatalogFile) - .filter((domain) => catalogDomainKey(domain.domain) !== null) + .filter((domain) => catalogDomainKey(domain.domain, options) !== null) .sort((a, b) => { - const aKey = catalogDomainKey(a.domain) ?? a.domain; - const bKey = catalogDomainKey(b.domain) ?? b.domain; + const aKey = catalogDomainKey(a.domain, options) ?? a.domain; + const bKey = catalogDomainKey(b.domain, options) ?? b.domain; return aKey.localeCompare(bKey) || a.domain.localeCompare(b.domain); }); return { domains }; @@ -213,25 +347,26 @@ export function readDomainCatalogTree(root = DEFAULT_DOMAIN_CATALOG_DIR): Catalo export function writeDomainCatalogTree( root: string, domains: readonly CatalogDomain[], + options?: CatalogDomainOptions, ): { written: number; changed: number; skipped: Array<{ domain: string; reason: string }> } { let written = 0; let changed = 0; const skipped: Array<{ domain: string; reason: string }> = []; const rows = [...domains].sort((a, b) => { - const aKey = catalogDomainKey(a.domain) ?? a.domain; - const bKey = catalogDomainKey(b.domain) ?? b.domain; + const aKey = catalogDomainKey(a.domain, options) ?? a.domain; + const bKey = catalogDomainKey(b.domain, options) ?? b.domain; return aKey.localeCompare(bKey) || a.domain.localeCompare(b.domain); }); for (const domain of rows) { - const key = catalogDomainKey(domain.domain); + const key = catalogDomainKey(domain.domain, options); if (!key) { skipped.push({ domain: domain.domain, reason: "invalid registrable domain" }); continue; } const path = domainFilePath(root, key); - const next = stableJson(domain); + const next = stableJson({ ...domain, domain: key }); const before = existsSync(path) ? readFileSync(path, "utf8") : undefined; mkdirSync(join(path, ".."), { recursive: true }); if (before !== next) { @@ -244,13 +379,13 @@ export function writeDomainCatalogTree( return { written, changed, skipped }; } -export function mergeCatalogs(existing: Catalog, incomingDomains: readonly CatalogDomain[]): CatalogMergeResult { +export function mergeCatalogs(existing: Catalog, incomingDomains: readonly CatalogDomain[], options?: CatalogDomainOptions): CatalogMergeResult { const byCanonical = new Map(); for (const domain of existing.domains ?? []) { - const key = catalogDomainKey(domain.domain); + const key = catalogDomainKey(domain.domain, options); if (!key) continue; const prior = byCanonical.get(key); - if (!prior || discoveredTime(domain) > discoveredTime(prior)) byCanonical.set(key, domain); + byCanonical.set(key, prior ? mergeCatalogDomainRows(prior, domain, key) : { ...domain, domain: key }); } const stats: CatalogMergeStats = { new: 0, updated: 0, unchanged: 0 }; @@ -258,27 +393,26 @@ export function mergeCatalogs(existing: Catalog, incomingDomains: readonly Catal const touched = new Set(); for (const incoming of incomingDomains) { - const key = catalogDomainKey(incoming.domain); + const key = catalogDomainKey(incoming.domain, options); if (!key) continue; touched.add(key); const prior = byCanonical.get(key); if (!prior) { - byCanonical.set(key, incoming); + byCanonical.set(key, { ...incoming, domain: key }); stats.new++; - changes.push({ kind: "new", domain: incoming.domain, nextDiscoveredAt: incoming.discoveredAt }); + changes.push({ kind: "new", domain: key, nextDiscoveredAt: incoming.discoveredAt }); continue; } - const priorTime = discoveredTime(prior); - const incomingTime = discoveredTime(incoming); - if (incomingTime > priorTime) { - byCanonical.set(key, incoming); + const merged = mergeCatalogDomainRows(prior, incoming, key); + if (stableJson(merged) !== stableJson(prior)) { + byCanonical.set(key, merged); stats.updated++; changes.push({ kind: "updated", - domain: incoming.domain, + domain: key, previousDiscoveredAt: prior.discoveredAt, - nextDiscoveredAt: incoming.discoveredAt, + nextDiscoveredAt: merged.discoveredAt, }); } else { stats.unchanged++; diff --git a/scripts/batch/redirect-canonical.test.ts b/scripts/batch/redirect-canonical.test.ts new file mode 100644 index 00000000..23dc6819 --- /dev/null +++ b/scripts/batch/redirect-canonical.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { probeRedirectCanonical, type FetchLike } from "./redirect-canonical.ts"; + +function response(url: string, status = 200, headers?: HeadersInit): Response { + const res = new Response(null, { status, headers }); + Object.defineProperty(res, "url", { value: url }); + return res; +} + +function mockFetch(routes: Record): FetchLike { + return async (input, init) => { + const key = `${init?.redirect ?? "follow"} ${String(input)}`; + const res = routes[key] ?? routes[String(input)]; + if (!res) throw new Error(`unexpected fetch ${key}`); + return res; + }; +} + +describe("probeRedirectCanonical", () => { + test("accepts a clean domain-wide apex redirect", async () => { + const decision = await probeRedirectCanonical("old.dev", { + fetchImpl: mockFetch({ + "follow https://old.dev/": response("https://new.com/"), + "follow https://old.dev/robots.txt": response("https://new.com/robots.txt", 404), + }), + secondPath: "/robots.txt", + }); + + expect(decision.kind).toBe("alias"); + if (decision.kind !== "alias") throw new Error("expected alias"); + expect(decision.source).toBe("old.dev"); + expect(decision.target).toBe("new.com"); + }); + + test("rejects a root-only redirect when a second path stays on the source", async () => { + const decision = await probeRedirectCanonical("docs.old.dev", { + fetchImpl: mockFetch({ + "follow https://docs.old.dev/": response("https://new.com/"), + "follow https://docs.old.dev/robots.txt": response("https://docs.old.dev/robots.txt", 200), + }), + secondPath: "/robots.txt", + }); + + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") throw new Error("expected rejection"); + expect(decision.reason).toBe("root-second-target-mismatch"); + }); + + test("rejects parked or aggregator targets", async () => { + const decision = await probeRedirectCanonical("abandoned.dev", { + fetchImpl: mockFetch({ + "follow https://abandoned.dev/": response("https://www.godaddy.com/forsale/abandoned.dev"), + }), + }); + + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") throw new Error("expected rejection"); + expect(decision.reason).toBe("denylisted-target"); + }); + + test("does not treat same-registrable www/apex redirects as aliases", async () => { + const decision = await probeRedirectCanonical("www.example.com", { + fetchImpl: mockFetch({ + "follow https://www.example.com/": response("https://example.com/"), + }), + }); + + expect(decision.kind).toBe("no_alias"); + if (decision.kind !== "no_alias") throw new Error("expected no_alias"); + expect(decision.reason).toBe("same-registrable"); + }); +}); diff --git a/scripts/batch/redirect-canonical.ts b/scripts/batch/redirect-canonical.ts new file mode 100644 index 00000000..fd342066 --- /dev/null +++ b/scripts/batch/redirect-canonical.ts @@ -0,0 +1,545 @@ +import { existsSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { + aliasMapWith, + DEFAULT_DOMAIN_CATALOG_DIR, + listDomainCatalogFiles, + mergeCatalogDomainRows, + readDomainCatalogFile, + stableJson, + type CatalogDomain, +} from "./discovered-catalog.ts"; +import { getFlag, getNumberFlag, hasFlag, mapLimit, parseArgs, registrable, ROOT, usage } from "./shared.ts"; + +const HELP = ` +Usage: bun scripts/batch/redirect-canonical.ts [--all | --domain example.com] [flags] + +Probes catalog domains for domain-wide cross-registrable redirects. + +Flags: + --all Probe every domains//integrations.json row + --domain domain Probe one domain (can be passed more than once) + --catalog-dir dir Catalog tree to scan/apply against (default: domains) + --apply Write accepted aliases and merge catalog duplicates + --apply-missing-targets Also write aliases whose target has no catalog record + --timeout-ms n Per-request timeout (default: 8000) + --concurrency n Parallel probes for --all (default: 8) + --verbose Include no-alias decisions in text output + --json Emit JSON instead of text + --help Show this help +`; + +const SECOND_PATH = "/.well-known/integrationsdotsh-redirect-probe"; +const DEFAULT_TIMEOUT_MS = 8000; +const DEFAULT_CONCURRENCY = 8; +const MAX_REDIRECTS = 10; +const PARKED_TARGETS = new Set([ + "dan.com", + "godaddy.com", + "google.com", + "hugedomains.com", + "linktr.ee", + "parkingcrew.net", + "sedo.com", +]); +const SOURCE_EXEMPTIONS = new Set([ + "amazonaws.com", + "amazonaws.com.cn", +]); + +export type FetchLike = (input: string | URL, init?: RequestInit) => Promise; + +export type RedirectHop = { + url: string; + status: number; + location?: string; +}; + +export type PathProbe = { + path: string; + startUrl: string; + finalUrl: string; + finalHostname: string | null; + finalRegistrable: string | null; + status: number; + chain: RedirectHop[]; +}; + +export type RedirectDecision = + | { + kind: "alias"; + source: string; + target: string; + reason: "domain-wide-redirect"; + root: PathProbe; + second: PathProbe; + } + | { + kind: "rejected"; + source: string; + target?: string; + reason: + | "deep-unrelated-final-path" + | "denylisted-target" + | "intermediate-unrelated-hop" + | "invalid-domain" + | "root-second-target-mismatch" + | "root-fetch-failed" + | "source-exempted" + | "second-fetch-failed"; + detail: string; + root?: PathProbe; + second?: PathProbe; + } + | { + kind: "no_alias"; + source: string; + reason: "same-registrable" | "no-registrable-target"; + detail: string; + root: PathProbe; + }; + +export type ProbeOptions = { + fetchImpl?: FetchLike; + timeoutMs?: number; + secondPath?: string; + trace?: boolean; +}; + +function normalizeDomain(domain: string): string { + return domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "").replace(/\.$/, ""); +} + +function probeUrl(domain: string, path: string): string { + return `https://${domain}${path}`; +} + +function hostnameOf(url: string): string | null { + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return null; + } +} + +function targetIsParked(registrableDomain: string | null, hostname: string | null): boolean { + if (!registrableDomain || PARKED_TARGETS.has(registrableDomain)) return Boolean(registrableDomain); + return Boolean(hostname && [...PARKED_TARGETS].some((target) => hostname === target || hostname.endsWith(`.${target}`))); +} + +function hasDeepPath(url: string): boolean { + try { + const path = new URL(url).pathname.replace(/\/+$/, ""); + return path.length > 0; + } catch { + return false; + } +} + +function chainRegistrables(chain: RedirectHop[]): string[] { + const out: string[] = []; + for (const hop of chain) { + for (const url of [hop.url, hop.location]) { + if (!url) continue; + const hostname = hostnameOf(url); + const domain = hostname ? registrable(hostname) : null; + if (domain) out.push(domain); + } + } + return [...new Set(out)]; +} + +function unrelatedIntermediateRegistrables(probe: PathProbe, sourceRegistrable: string, target: string): string[] { + const registrables = chainRegistrables(probe.chain); + return registrables.filter((domain) => domain !== sourceRegistrable && domain !== target); +} + +async function fetchWithTimeout(fetchImpl: FetchLike, url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetchImpl(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function traceRedirects(fetchImpl: FetchLike, startUrl: string, timeoutMs: number): Promise { + const chain: RedirectHop[] = []; + let current = startUrl; + for (let i = 0; i <= MAX_REDIRECTS; i++) { + const res = await fetchWithTimeout(fetchImpl, current, { redirect: "manual" }, timeoutMs); + const rawLocation = res.headers.get("location") ?? undefined; + const location = rawLocation ? new URL(rawLocation, current).toString() : undefined; + chain.push({ url: current, status: res.status, ...(location ? { location } : {}) }); + if (res.status < 300 || res.status >= 400 || !location) return chain; + current = location; + } + return chain; +} + +async function probePath(domain: string, path: string, options: Required> & { fetchImpl: FetchLike }): Promise { + const startUrl = probeUrl(domain, path); + const res = await fetchWithTimeout(options.fetchImpl, startUrl, { redirect: "follow" }, options.timeoutMs); + const finalUrl = res.url || startUrl; + const finalHostname = hostnameOf(finalUrl); + const chain = options.trace && finalUrl !== startUrl + ? await traceRedirects(options.fetchImpl, startUrl, options.timeoutMs) + : [{ url: startUrl, status: res.status }]; + return { + path, + startUrl, + finalUrl, + finalHostname, + finalRegistrable: finalHostname ? registrable(finalHostname) : null, + status: res.status, + chain, + }; +} + +export async function probeRedirectCanonical(domain: string, options: ProbeOptions = {}): Promise { + const source = normalizeDomain(domain); + const sourceRegistrable = registrable(source); + if (!sourceRegistrable) { + return { + kind: "rejected", + source, + reason: "invalid-domain", + detail: "source domain does not have a valid registrable domain", + }; + } + + if (SOURCE_EXEMPTIONS.has(source)) { + return { + kind: "rejected", + source, + reason: "source-exempted", + detail: "source apex redirects, but subdomains under this registrable domain are independent service endpoints", + }; + } + + const probeOptions = { + fetchImpl: options.fetchImpl ?? fetch, + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + trace: options.trace ?? false, + }; + + let root: PathProbe; + try { + root = await probePath(source, "/", probeOptions); + } catch (err) { + return { + kind: "rejected", + source, + reason: "root-fetch-failed", + detail: (err as Error).message, + }; + } + + if (!root.finalRegistrable) { + return { + kind: "no_alias", + source, + reason: "no-registrable-target", + detail: `root landed on ${root.finalUrl}`, + root, + }; + } + + if (root.finalRegistrable === sourceRegistrable) { + return { + kind: "no_alias", + source, + reason: "same-registrable", + detail: `${sourceRegistrable} stayed within the same registrable domain`, + root, + }; + } + + const target = root.finalRegistrable; + if (targetIsParked(target, root.finalHostname)) { + return { + kind: "rejected", + source, + target, + reason: "denylisted-target", + detail: `root landed on parked/aggregator target ${root.finalHostname ?? target}`, + root, + }; + } + + const unrelatedIntermediates = unrelatedIntermediateRegistrables(root, sourceRegistrable, target); + if (unrelatedIntermediates.length > 0) { + return { + kind: "rejected", + source, + target, + reason: "intermediate-unrelated-hop", + detail: `redirect chain passed through unrelated domain(s): ${unrelatedIntermediates.join(", ")}`, + root, + }; + } + + if (hasDeepPath(root.finalUrl)) { + return { + kind: "rejected", + source, + target, + reason: "deep-unrelated-final-path", + detail: `root landed on non-root path ${new URL(root.finalUrl).pathname}`, + root, + }; + } + + let second: PathProbe; + try { + second = await probePath(source, options.secondPath ?? SECOND_PATH, probeOptions); + } catch (err) { + return { + kind: "rejected", + source, + target, + reason: "second-fetch-failed", + detail: (err as Error).message, + root, + }; + } + + if (second.finalRegistrable !== target) { + return { + kind: "rejected", + source, + target, + reason: "root-second-target-mismatch", + detail: `root landed on ${target}, second path landed on ${second.finalRegistrable ?? second.finalUrl}`, + root, + second, + }; + } + + if (targetIsParked(second.finalRegistrable, second.finalHostname)) { + return { + kind: "rejected", + source, + target, + reason: "denylisted-target", + detail: `second path landed on parked/aggregator target ${second.finalHostname ?? target}`, + root, + second, + }; + } + + return { + kind: "alias", + source, + target, + reason: "domain-wide-redirect", + root, + second, + }; +} + +export async function probeRedirectCanonicals(domains: string[], options: ProbeOptions & { concurrency?: number } = {}): Promise { + const unique = [...new Set(domains.map(normalizeDomain).filter(Boolean))].sort(); + const concurrency = Math.max(1, options.concurrency ?? DEFAULT_CONCURRENCY); + return mapLimit(unique, concurrency, (domain) => probeRedirectCanonical(domain, options)); +} + +function parseAliasEntries(source: string): Record { + const match = source.match(/export const DOMAIN_ALIASES: Record = \{\n([\s\S]*?)\n\};/); + if (!match) throw new Error("Could not find DOMAIN_ALIASES object"); + const entries: Record = {}; + for (const line of match[1]!.split(/\r?\n/)) { + const entry = line.match(/^\s*"([^"]+)":\s*"([^"]+)",\s*$/); + if (entry) entries[entry[1]!] = entry[2]!; + } + return entries; +} + +export function upsertDomainAliasesFile( + aliases: readonly { source: string; target: string }[], + aliasPath = join(ROOT, "src", "lib", "domain-aliases.ts"), +): { changed: boolean; added: Array<{ source: string; target: string }> } { + const before = readFileSync(aliasPath, "utf8"); + const current = parseAliasEntries(before); + const next = { ...current }; + const added: Array<{ source: string; target: string }> = []; + + for (const alias of aliases) { + const source = normalizeDomain(alias.source); + let target = normalizeDomain(alias.target); + while (next[target]) target = next[target]!; + if (!source || !target || source === target) continue; + if (next[source] === target) continue; + next[source] = target; + added.push({ source, target }); + } + + if (added.length === 0) return { changed: false, added }; + for (const [source, target] of Object.entries(next)) { + if (!source || !target || source === target || next[target]) { + throw new Error(`Invalid domain alias after update: ${source} -> ${target}`); + } + } + + const body = Object.entries(next) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([source, target]) => ` "${source}": "${target}",`) + .join("\n"); + const after = before.replace( + /export const DOMAIN_ALIASES: Record = \{\n[\s\S]*?\n\};/, + `export const DOMAIN_ALIASES: Record = {\n${body}\n};`, + ); + if (after !== before) writeFileSync(aliasPath, after); + return { changed: after !== before, added }; +} + +function catalogPath(root: string, domain: string): string { + return join(root, domain, "integrations.json"); +} + +function hasCatalogRecord(root: string, domain: string): boolean { + return existsSync(catalogPath(root, domain)); +} + +function removeEmptyDomainDir(root: string, domain: string): void { + const dir = join(root, domain); + if (!existsSync(dir)) return; + if (readdirSync(dir).length > 0) return; + rmdirSync(dir); +} + +function writeCatalogDomain(root: string, domain: CatalogDomain): boolean { + const path = catalogPath(root, domain.domain); + mkdirSync(join(path, ".."), { recursive: true }); + const next = stableJson(domain); + const before = existsSync(path) ? readFileSync(path, "utf8") : undefined; + if (before === next) return false; + writeFileSync(path, next); + return true; +} + +export function mergeAliasCatalogFiles( + root: string, + aliases: readonly { source: string; target: string }[], +): Array<{ source: string; target: string; action: "merged" | "moved" | "canonicalized" | "missing" }> { + const results: Array<{ source: string; target: string; action: "merged" | "moved" | "canonicalized" | "missing" }> = []; + for (const alias of aliases) { + const source = normalizeDomain(alias.source); + const target = normalizeDomain(aliasMapWith({ [source]: alias.target })[source] ?? alias.target); + if (!source || !target || source === target) continue; + const sourcePath = catalogPath(root, source); + const targetPath = catalogPath(root, target); + const sourceExists = existsSync(sourcePath); + const targetExists = existsSync(targetPath); + + if (sourceExists && targetExists) { + const canonical = readDomainCatalogFile(targetPath); + const dropped = readDomainCatalogFile(sourcePath); + writeCatalogDomain(root, mergeCatalogDomainRows(canonical, dropped, target)); + unlinkSync(sourcePath); + removeEmptyDomainDir(root, source); + results.push({ source, target, action: "merged" }); + } else if (sourceExists) { + const moved = { ...readDomainCatalogFile(sourcePath), domain: target }; + writeCatalogDomain(root, moved); + unlinkSync(sourcePath); + removeEmptyDomainDir(root, source); + results.push({ source, target, action: "moved" }); + } else if (targetExists) { + const canonical = readDomainCatalogFile(targetPath); + const changed = writeCatalogDomain(root, { ...canonical, domain: target }); + results.push({ source, target, action: changed ? "canonicalized" : "missing" }); + } else { + results.push({ source, target, action: "missing" }); + } + } + return results; +} + +function chainText(probe: PathProbe | undefined): string { + if (!probe) return ""; + return probe.chain + .map((hop) => `${hop.status} ${hop.url}${hop.location ? ` -> ${hop.location}` : ""}`) + .join(" | "); +} + +function formatDecision(decision: RedirectDecision, verbose: boolean): string | null { + if (decision.kind === "alias") { + return [ + `ALIAS ${decision.source} -> ${decision.target}`, + ` / final: ${decision.root.finalUrl}`, + ` / chain: ${chainText(decision.root)}`, + ` ${decision.second.path} final: ${decision.second.finalUrl}`, + ` ${decision.second.path} chain: ${chainText(decision.second)}`, + ].join("\n"); + } + if (decision.kind === "rejected") { + return [ + `REJECT ${decision.source}${decision.target ? ` -> ${decision.target}` : ""} (${decision.reason})`, + ` ${decision.detail}`, + decision.root ? ` / final: ${decision.root.finalUrl}` : undefined, + decision.second ? ` ${decision.second.path} final: ${decision.second.finalUrl}` : undefined, + ].filter(Boolean).join("\n"); + } + if (!verbose) return null; + return `NO_ALIAS ${decision.source} (${decision.reason}) ${decision.detail}`; +} + +function domainsFromCatalog(root: string): string[] { + return listDomainCatalogFiles(root).map((path) => readDomainCatalogFile(path).domain); +} + +async function main(): Promise { + const args = parseArgs(); + if (hasFlag(args, "help")) usage(HELP); + + const catalogDir = resolve(ROOT, getFlag(args, "catalog-dir", DEFAULT_DOMAIN_CATALOG_DIR)!); + const explicitDomains = args.flags.get("domain") ?? []; + const domains = hasFlag(args, "all") + ? domainsFromCatalog(catalogDir) + : [...explicitDomains, ...args.positionals]; + if (domains.length === 0) usage(HELP); + + const decisions = await probeRedirectCanonicals(domains, { + timeoutMs: getNumberFlag(args, "timeout-ms", DEFAULT_TIMEOUT_MS), + concurrency: getNumberFlag(args, "concurrency", DEFAULT_CONCURRENCY), + trace: true, + }); + + const accepted = decisions.filter((decision): decision is Extract => decision.kind === "alias"); + const rejected = decisions.filter((decision) => decision.kind === "rejected"); + const noAlias = decisions.filter((decision) => decision.kind === "no_alias"); + + if (hasFlag(args, "json")) { + console.log(JSON.stringify({ accepted, rejected, noAlias }, null, 2)); + } else { + for (const decision of decisions) { + const line = formatDecision(decision, hasFlag(args, "verbose")); + if (line) console.log(line); + } + console.log(`summary: accepted=${accepted.length} rejected=${rejected.length} no_alias=${noAlias.length}`); + } + + if (!hasFlag(args, "apply")) return; + + const acceptedAliases = accepted.map((decision) => ({ source: decision.source, target: decision.target })); + const applyMissingTargets = hasFlag(args, "apply-missing-targets"); + const newAliases = applyMissingTargets + ? acceptedAliases + : acceptedAliases.filter((alias) => hasCatalogRecord(catalogDir, alias.target)); + const skippedMissingTargets = acceptedAliases.filter((alias) => !newAliases.some((applied) => applied.source === alias.source)); + const aliasWrite = upsertDomainAliasesFile(newAliases); + const mergeResults = mergeAliasCatalogFiles(catalogDir, newAliases); + if (!hasFlag(args, "json")) { + console.log(`aliases file changed=${aliasWrite.changed} added=${aliasWrite.added.length}`); + for (const alias of skippedMissingTargets) { + console.log(`alias skipped missing target catalog record: ${alias.source} -> ${alias.target}`); + } + for (const result of mergeResults) { + console.log(`catalog ${result.action}: ${result.source} -> ${result.target}`); + } + } +} + +if (import.meta.main) await main(); diff --git a/scripts/batch/sync-kv.ts b/scripts/batch/sync-kv.ts index ceb71220..bff2f322 100644 --- a/scripts/batch/sync-kv.ts +++ b/scripts/batch/sync-kv.ts @@ -1,7 +1,8 @@ -import { mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { + aliasMapWith, catalogDomainFromLooseStored, catalogDomainKey, DEFAULT_DOMAIN_CATALOG_DIR, @@ -11,7 +12,9 @@ import { type CatalogDomain, writeDomainCatalogTree, } from "./discovered-catalog.ts"; -import { getFlag, hasFlag, parseArgs, ROOT, usage } from "./shared.ts"; +import { DOMAIN_ALIASES } from "../../src/lib/domain-aliases.ts"; +import { mergeAliasCatalogFiles, probeRedirectCanonicals, upsertDomainAliasesFile } from "./redirect-canonical.ts"; +import { getFlag, getNumberFlag, hasFlag, parseArgs, ROOT, usage } from "./shared.ts"; const DISCOVERY_NAMESPACE_ID = "7456151d9722471ca000f6d3b03a62c7"; const BULK_CHUNK_SIZE = 100; @@ -25,6 +28,9 @@ Flags: --local Read local wrangler KV instead of production KV --dry-run Print counts and a sample diff, write nothing --out dir Catalog tree output directory (default: domains) + --skip-redirect-probe Do not probe newly synced domains for canonical redirects + --redirect-timeout-ms n Per-request redirect probe timeout (default: 8000) + --redirect-concurrency n Parallel redirect probes (default: 8) --help Show this help `; @@ -152,8 +158,8 @@ function sampleDiff(changes: ReturnType["changes"], limit return `sample diff:\n${lines.join("\n")}`; } -export function mergeDiscoveredCatalog(existing: Catalog, incoming: readonly CatalogDomain[]): ReturnType { - return mergeCatalogs(existing, incoming); +export function mergeDiscoveredCatalog(existing: Catalog, incoming: readonly CatalogDomain[], options?: { aliases?: Record }): ReturnType { + return mergeCatalogs(existing, incoming, options); } async function main(): Promise { @@ -201,8 +207,39 @@ async function main(): Promise { } } - const existing = readDomainCatalogTree(outDir); - const merged = mergeDiscoveredCatalog(existing, incoming); + const incomingTargets = new Set(incoming.map((domain) => catalogDomainKey(domain.domain)).filter((domain): domain is string => Boolean(domain))); + const hasAliasTarget = (target: string): boolean => incomingTargets.has(target) || existsSync(join(outDir, target, "integrations.json")); + const acceptedAliases: Array<{ source: string; target: string }> = []; + if (!hasFlag(args, "skip-redirect-probe")) { + const probeDomains = incoming + .map((domain) => domain.domain.toLowerCase()) + .filter((domain) => !DOMAIN_ALIASES[domain]); + const redirectDecisions = await probeRedirectCanonicals(probeDomains, { + timeoutMs: getNumberFlag(args, "redirect-timeout-ms", 8000), + concurrency: getNumberFlag(args, "redirect-concurrency", 8), + trace: true, + }); + for (const decision of redirectDecisions) { + if (decision.kind === "alias") { + console.log(`redirect-probe: accepted ${decision.source} -> ${decision.target}`); + if (hasAliasTarget(decision.target)) { + acceptedAliases.push({ source: decision.source, target: decision.target }); + } else { + console.log(`redirect-probe: skipped ${decision.source} -> ${decision.target}: target catalog record not found`); + } + } else if (decision.kind === "rejected") { + console.log(`redirect-probe: rejected ${decision.source}${decision.target ? ` -> ${decision.target}` : ""} (${decision.reason}): ${decision.detail}`); + } + } + if (acceptedAliases.length > 0 && !dryRun) { + const aliasWrite = upsertDomainAliasesFile(acceptedAliases); + console.log(`redirect-probe: aliases file changed=${aliasWrite.changed} added=${aliasWrite.added.length}`); + } + } + + const catalogOptions = { aliases: aliasMapWith(Object.fromEntries(acceptedAliases.map((alias) => [alias.source, alias.target]))) }; + const existing = readDomainCatalogTree(outDir, catalogOptions); + const merged = mergeDiscoveredCatalog(existing, incoming, catalogOptions); const summary: SyncSummary = { keysListed: keys.length, skippedInvalid, @@ -228,10 +265,13 @@ async function main(): Promise { return; } - const written = writeDomainCatalogTree(outDir, merged.catalog.domains); + const written = writeDomainCatalogTree(outDir, merged.catalog.domains, catalogOptions); for (const skip of written.skipped) { console.warn(`sync-kv: skipped ${skip.domain}: ${skip.reason}`); } + for (const result of mergeAliasCatalogFiles(outDir, acceptedAliases)) { + console.log(`redirect-probe: catalog ${result.action} ${result.source} -> ${result.target}`); + } console.log(`wrote ${written.written} domain files to ${outDir} (${written.changed} changed)`); } diff --git a/src/lib/domain-aliases.test.ts b/src/lib/domain-aliases.test.ts index dad62031..9e31ef8f 100644 --- a/src/lib/domain-aliases.test.ts +++ b/src/lib/domain-aliases.test.ts @@ -9,6 +9,7 @@ describe("domain aliases", () => { expect(canonicalDomain("Vercel.SH")).toBe("vercel.com"); expect(canonicalDomain("zeit.co")).toBe("vercel.com"); expect(canonicalDomain("railway.app")).toBe("railway.com"); + expect(canonicalDomain("pscale.dev")).toBe("planetscale.com"); expect(canonicalDomain(" zoom.us ")).toBe("zoom.com"); }); diff --git a/src/lib/domain-aliases.ts b/src/lib/domain-aliases.ts index 292c9f02..2cc0cc2f 100644 --- a/src/lib/domain-aliases.ts +++ b/src/lib/domain-aliases.ts @@ -2,18 +2,29 @@ * Canonical direction follows the vendor's own apex redirect * (e.g. sentry.dev 307s to sentry.io, vercel.sh 308s to vercel.com). */ export const DOMAIN_ALIASES: Record = { + "ably.io": "ably.com", + "ably.net": "ably.com", "addressy.com": "loqate.com", + "adobe.io": "adobe.com", "adyen.help": "adyen.com", "airbyte.io": "airbyte.com", + "alpic.live": "alpic.ai", + "amentum.space": "amentum.io", "angular.io": "angular.dev", + "apidocumentation.com": "scalar.com", "apitoolkit.io": "monoscope.tech", "atlassian.net": "atlassian.com", + "auraintel.com": "getaura.ai", + "auraintelligence.com": "getaura.ai", + "avatars1.githubusercontent.com": "github.com", + "bclaws.ca": "gov.bc.ca", "benevity.org": "benevity.com", "bigdatacloud.net": "bigdatacloud.com", "bit.ly": "bitly.com", "blueconic.net": "blueconic.com", "braze.eu": "braze.com", "bridgedb.github.io": "bridgedb.org", + "bufferapp.com": "buffer.com", "bungie.com": "bungie.net", "cloud.sap": "sap.com", "contentstack.io": "contentstack.com", @@ -21,36 +32,54 @@ export const DOMAIN_ALIASES: Record = { "dbt.com": "getdbt.com", "discord.gg": "discord.com", "docker.io": "docker.com", + "domotz.app": "domotz.com", "eodhistoricaldata.com": "eodhd.com", + "ethoslife.com": "ethos.com", "eventbriteapi.com": "eventbrite.com", "factset.io": "factset.com", + "fastmcp.cloud": "prefect.io", "fathom.video": "fathom.ai", "fellow.app": "fellow.ai", + "formapi.io": "docspring.com", "frontapp.com": "front.com", + "getmiso.com": "miso.kr", "getpinwheel.com": "pinwheelapi.com", "gist.githubusercontent.com": "github.com", + "gitea.io": "gitea.com", "graphite.dev": "graphite.com", "heapanalytics.com": "heap.io", + "hellosign.com": "dropbox.com", "helpscout.net": "helpscout.com", "hyperping.io": "hyperping.com", "ift.tt": "ifttt.com", "intercom.io": "intercom.com", "letsdeel.com": "deel.com", "logtail.com": "betterstack.com", + "mashape.com": "konghq.com", + "mdsol.com": "medidata.com", "meetcampfire.com": "campfire.ai", "mermaidchart.com": "mermaid.ai", + "motos.net": "coches.net", "neon.tech": "neon.com", + "nexmo.com": "vonage.com", + "nfusionsolutions.biz": "nfusionsolutions.com", "npmjs.org": "npmjs.com", "paystack.co": "paystack.com", + "polygon.io": "massive.com", + "pscale.dev": "planetscale.com", "railway.app": "railway.com", + "readme.io": "readme.com", "rfpio.com": "responsive.io", + "rumble.run": "runzero.com", "rutterapi.com": "rutter.com", "sentry.dev": "sentry.io", "shippo.com": "goshippo.com", "signoz.cloud": "signoz.io", + "sitejabber.com": "smartcustomer.com", "snowplowanalytics.com": "snowplow.io", "storage.dev": "tigrisdata.com", "storecove.nl": "storecove.com", + "strivemaths.com": "strivemath.com", "swell.store": "swell.is", "t.me": "telegram.org", "tafkit.com": "tafqit.com", @@ -58,13 +87,19 @@ export const DOMAIN_ALIASES: Record = { "timescale.com": "tigerdata.com", "tray.io": "tray.ai", "turso.ai": "turso.tech", + "twitter.com": "x.com", "ur.com": "unitedrentals.com", "vercel.sh": "vercel.com", + "watchful.li": "watchful.net", "zeit.co": "vercel.com", "zep.ai": "getzep.com", "zoho.com.au": "zoho.com", + "zoho.eu": "zoho.com", + "zoho.in": "zoho.com", "zoho.jp": "zoho.com", + "zoho.sa": "zoho.com", "zoho.uk": "zoho.com", + "zohocloud.ca": "zoho.com", "zohomcp.eu": "zoho.com", "zohomcp.in": "zoho.com", "zoom.us": "zoom.com", From 0469bb3b76fa1243b2fa7de85878f50b9e5b7f49 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:16:52 -0700 Subject: [PATCH 2/3] Harden redirect canonical probe guards --- .gitignore | 2 + output-last-message | 669 ----------------------- scripts/batch/redirect-canonical.test.ts | 92 +++- scripts/batch/redirect-canonical.ts | 52 ++ scripts/batch/sync-kv.ts | 2 + 5 files changed, 142 insertions(+), 675 deletions(-) delete mode 100644 output-last-message diff --git a/.gitignore b/.gitignore index a2313a22..75e26895 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ dist/ .astro/ .DS_Store *.log +output-last-message +.codex-prompt-*.md .dev.vars .wrangler/ .reference/ diff --git a/output-last-message b/output-last-message deleted file mode 100644 index 8da0bba0..00000000 --- a/output-last-message +++ /dev/null @@ -1,669 +0,0 @@ -# follow redirects to canonicalize domains + merge duplicates - -## What changed -- Added `scripts/batch/redirect-canonical.ts`, a redirect probe/CLI that follows `https:///`, checks a second path, rejects same-registrable `www`/apex flips, reject-lists parked targets, rejects deep unrelated final paths, rejects unrelated intermediate-hop chains, and has source exemptions for apex redirects that should not collapse service endpoint domains. -- Wired redirect probing into `scripts/batch/sync-kv.ts`; accepted aliases are written only when the canonical target has a catalog row in the incoming batch or existing tree. -- Changed catalog merging to field-safe alias merges: canonical scalar fields win; surfaces are unioned and compact duplicate display identities collapse; CLI package registries are unioned through `dedupSurfaces`. -- Added `bun run redirect:canonical` as the standalone entry point. -- Applied catalog-backed aliases found by the probe, including `pscale.dev -> planetscale.com`, and removed alias-side catalog records after merging. - -## Files touched -- Code/tests: `package.json`, `scripts/batch/redirect-canonical.ts`, `scripts/batch/redirect-canonical.test.ts`, `scripts/batch/dedup.ts`, `scripts/batch/dedup.test.ts`, `scripts/batch/discovered-catalog.ts`, `scripts/batch/discovered-catalog.test.ts`, `scripts/batch/sync-kv.ts`, `src/lib/domain-aliases.ts`, `src/lib/domain-aliases.test.ts`. -- Catalog data: merged alias records for the applied aliases listed below; alias-side `domains//integrations.json` files were removed and canonical `domains//integrations.json` files were updated. - -## Applied aliases -- ably.io -> ably.com -- ably.net -> ably.com -- adobe.io -> adobe.com -- alpic.live -> alpic.ai -- amentum.space -> amentum.io -- apidocumentation.com -> scalar.com -- auraintel.com -> getaura.ai -- auraintelligence.com -> getaura.ai -- avatars1.githubusercontent.com -> github.com -- bclaws.ca -> gov.bc.ca -- bufferapp.com -> buffer.com -- domotz.app -> domotz.com -- ethoslife.com -> ethos.com -- fastmcp.cloud -> prefect.io -- formapi.io -> docspring.com -- getmiso.com -> miso.kr -- gitea.io -> gitea.com -- hellosign.com -> dropbox.com -- mashape.com -> konghq.com -- mdsol.com -> medidata.com -- motos.net -> coches.net -- nexmo.com -> vonage.com -- nfusionsolutions.biz -> nfusionsolutions.com -- polygon.io -> massive.com -- pscale.dev -> planetscale.com -- readme.io -> readme.com -- rumble.run -> runzero.com -- sitejabber.com -> smartcustomer.com -- strivemaths.com -> strivemath.com -- twitter.com -> x.com -- watchful.li -> watchful.net -- zoho.eu -> zoho.com -- zoho.in -> zoho.com -- zoho.sa -> zoho.com -- zohocloud.ca -> zoho.com - -## Probe report -- Accepted proposals: 89 -- Rejected decisions: 494 -- No-alias decisions: 2595 -- Accepted but not applied because the target catalog record was absent: 54 - -## Accepted proposals -- ably.io -> ably.com (applied) -- ably.net -> ably.com (applied) -- adobe.io -> adobe.com (applied) -- aifortunedoctor.com -> cheongwoldang.com (reported-only) -- alpic.live -> alpic.ai (applied) -- amentum.space -> amentum.io (applied) -- aoncadis.org -> arcticdata.io (reported-only) -- apidocumentation.com -> scalar.com (applied) -- auraintel.com -> getaura.ai (applied) -- auraintelligence.com -> getaura.ai (applied) -- authkit.app -> authkit.com (reported-only) -- avatars1.githubusercontent.com -> github.com (applied) -- b-cdn.net -> bunny.net (reported-only) -- bajajallianzlife.com -> bajajlifeinsurance.com (reported-only) -- bclaws.ca -> gov.bc.ca (applied) -- bhagavadgita.io -> bhagavadgita.com (reported-only) -- bufferapp.com -> buffer.com (applied) -- cambase.io -> mpe.co.com (reported-only) -- cbse.nic.in -> cbse.gov.in (reported-only) -- cenit.io -> rocketplay-australia.com (reported-only) -- charm.sh -> charm.land (reported-only) -- clickmeter.com -> rebrandly.com (reported-only) -- craime.se -> lagenta.se (reported-only) -- credas.co.uk -> credas.com (reported-only) -- cybertaxonomy.eu -> cybertaxonomy.org (reported-only) -- data2crm.com -> syncmatters.com (reported-only) -- directus.io -> directus.com (reported-only) -- domotz.app -> domotz.com (applied) -- enode.io -> enode.com (reported-only) -- ethoslife.com -> ethos.com (applied) -- fastmcp.cloud -> prefect.io (applied) -- formapi.io -> docspring.com (applied) -- frankiefinancial.com -> frankieone.com (reported-only) -- getmiso.com -> miso.kr (applied) -- getpostman.com -> postman.com (reported-only) -- gitea.io -> gitea.com (applied) -- greenhouse.io -> greenhouse.com (reported-only) -- hellosign.com -> dropbox.com (applied) -- highwaysengland.co.uk -> nationalhighways.co.uk (reported-only) -- httpie.org -> httpie.io (reported-only) -- hubapi.com -> hubspot.com (reported-only) -- ijenko.net -> astucestechnologiques.com (reported-only) -- illumidesk.com -> illumichat.com (reported-only) -- inpost-group.com -> inpost.eu (reported-only) -- jasig.org -> apereo.org (reported-only) -- lambdatest.com -> testmuai.com (reported-only) -- lmnr.ai -> laminar.sh (reported-only) -- mashape.com -> konghq.com (applied) -- mcp.run -> turbomcp.ai (reported-only) -- mdsol.com -> medidata.com (applied) -- miguel76.github.io -> miguel76.io (reported-only) -- morth.nic.in -> morth.gov.in (reported-only) -- motos.net -> coches.net (applied) -- nexmo.com -> vonage.com (applied) -- nfusionsolutions.biz -> nfusionsolutions.com (applied) -- ory.sh -> ory.com (reported-only) -- outreach.io -> outreach.ai (reported-only) -- phantauth-chance.now.sh -> phantauth-chance.vercel.app (reported-only) -- phantauth-faker.now.sh -> phantauth-faker.vercel.app (reported-only) -- polygon.io -> massive.com (applied) -- prss.org -> nprdistribution.org (reported-only) -- pscale.dev -> planetscale.com (applied) -- ptv.vic.gov.au -> transport.vic.gov.au (reported-only) -- pubmed.gov -> nih.gov (reported-only) -- raxeltelematics.com -> damoov.com (reported-only) -- readme.io -> readme.com (applied) -- redis.com -> redis.io (reported-only) -- reliancegeneral.co.in -> indusindinsurance.com (reported-only) -- rumble.run -> runzero.com (applied) -- semaphoreci.com -> semaphore.io (reported-only) -- sf.net -> sourceforge.net (reported-only) -- sitejabber.com -> smartcustomer.com (applied) -- sourceforge.io -> sourceforge.net (reported-only) -- stream.io -> garbesi.domains (reported-only) -- strivemaths.com -> strivemath.com (applied) -- telematicssdk.com -> damoov.com (reported-only) -- traktorpool.com -> tractorpool.com (reported-only) -- trapstreet.com -> exponentapparel.com (reported-only) -- trymalcolm.com -> onmarrow.com (reported-only) -- twitter.com -> x.com (applied) -- unavatar.now.sh -> unavatar.vercel.app (reported-only) -- urlbox.io -> urlbox.com (reported-only) -- userdatahub.com -> damoov.com (reported-only) -- watchful.li -> watchful.net (applied) -- youneedabudget.com -> ynab.com (reported-only) -- zoho.eu -> zoho.com (applied) -- zoho.in -> zoho.com (applied) -- zoho.sa -> zoho.com (applied) -- zohocloud.ca -> zoho.com (applied) - -## Rejected decisions -- 2.final: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- 3scale.net -> redhat.com: deep-unrelated-final-path; root landed on non-root path /en/technologies/jboss-middleware/3scale -- abema-tv.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- abhibus-com-s-account.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- accessoticketing.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- account.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- accuweather.com: root-fetch-failed; The operation was aborted. -- adobelogin.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://adobelogin.com/". For more information, pass `verbose: true` in the second argument to fetch() -- afraid.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://afraid.org/". For more information, pass `verbose: true` in the second argument to fetch() -- agco-ats.com: root-fetch-failed; The operation was aborted. -- akqa-emea-starbucks-chatgpt-app.vercel.app -> starbucks.com: root-second-target-mismatch; root landed on starbucks.com, second path landed on akqa-emea-starbucks-chatgpt-app.vercel.app -- alertersystem.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- allapp.net: root-fetch-failed; The operation was aborted. -- alpic.cloud: root-fetch-failed; The operation was aborted. -- amazonaws.com -> amazon.com: source-exempted; source apex redirects, but subdomains under this registrable domain are independent service endpoints -- amazonaws.com.cn -> amazonaws.cn: source-exempted; source apex redirects, but subdomains under this registrable domain are independent service endpoints -- amplify.aws -> amazon.com: deep-unrelated-final-path; root landed on non-root path /amplify/ -- analytics.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- anchore.io -> anchore.com: root-second-target-mismatch; root landed on anchore.com, second path landed on anchore.io -- andaman.gov.in: root-fetch-failed; The operation was aborted. -- apartmentlist.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apddev.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- api.aws: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- api2crm.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apidapp.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apigee.com -> google.com: denylisted-target; root landed on parked/aggregator target cloud.google.com -- apigee.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apinf.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apinf.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- apispot.io -> bouquet-liban.tv: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): anagomez.com.mx, 7m8.org -- appauthenticator.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- applicationinsights.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- arespass.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- armory.io: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://armory.io/". For more information, pass `verbose: true` in the second argument to fetch() -- arunachal.gov.in: root-fetch-failed; The operation was aborted. -- atcloud.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- auth0-extend.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- authenticators.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- authentiq.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- automations.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- aviationdata.systems: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- azure.com -> microsoft.com: deep-unrelated-final-path; root landed on non-root path /en-us -- azurecontainerapps.io -> microsoft.com: deep-unrelated-final-path; root landed on non-root path /en-us -- basecampapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- basic.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bbci.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- beanstream.com -> shift4.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): bambora.com, worldline.com -- beerfinder.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bestsolo401k.app: root-fetch-failed; The operation was aborted. -- bettermg.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bgbm.org -> bo.berlin: root-second-target-mismatch; root landed on bo.berlin, second path landed on bgbm.org -- bharatpetroleum.com: root-fetch-failed; The operation was aborted. -- bih.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bihar.gov.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bikewise.org -> bikeindex.org: deep-unrelated-final-path; root landed on non-root path /info/bikewise -- bitdj.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- blabla.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bluemix.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bluerain.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- bon-coin.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- box.dev -> box.com: deep-unrelated-final-path; root landed on non-root path /guides -- boxoffice.com: root-fetch-failed; certificate has expired -- boxyhq.com -> ory.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): ory.sh -- bpcs-ad-broker.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- braintreepayments.com -> paypal.com: deep-unrelated-final-path; root landed on non-root path /us/braintree -- brands.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- brex.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- brexapis.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- britbox.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- busbud-int.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://busbud-int.com/". For more information, pass `verbose: true` in the second argument to fetch() -- businessreports.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- byautomata.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- campaigns.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ccp.is: root-fetch-failed; The operation was aborted. -- cfdomains.com: root-fetch-failed; The operation was aborted. -- cgbse.nic.in: root-fetch-failed; The operation was aborted. -- channel4.co.uk: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://channel4.co.uk/". For more information, pass `verbose: true` in the second argument to fetch() -- chdservices.gov.in: root-fetch-failed; The operation was aborted. -- chrome.com -> google.com: denylisted-target; root landed on parked/aggregator target www.google.com -- circuit.com: root-fetch-failed; unable to verify the first certificate -- circuitsandbox.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- citrixonline.com: root-fetch-failed; The operation was aborted. -- citycontext.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://citycontext.com/". For more information, pass `verbose: true` in the second argument to fetch() -- clever-cloud.com -> clever.cloud: root-second-target-mismatch; root landed on clever.cloud, second path landed on clever-cloud.com -- climatekuul.com: root-fetch-failed; The operation was aborted. -- cloud-elements.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://cloud-elements.com/". For more information, pass `verbose: true` in the second argument to fetch() -- cloudshell.googleapis.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com -- cmcp.shop: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- code-scan.com: root-fetch-failed; The operation was aborted. -- codefresh.io -> octopus.com: deep-unrelated-final-path; root landed on non-root path /codefresh -- columnapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- commercejs.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- communitygraph.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- companyprofile.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- contacts.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- content.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- contract-p.fit -> contract.fit: deep-unrelated-final-path; root landed on non-root path /tenantless/en/login -- contract.fit: root-fetch-failed; The operation was aborted. -- cowin.gov.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- cpy.re: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://cpy.re/". For more information, pass `verbose: true` in the second argument to fetch() -- crbonfree.com -> crbonlabs.com: deep-unrelated-final-path; root landed on non-root path /crbonfree -- creditkarma.com: root-fetch-failed; The operation was aborted. -- crediwatch.com: root-fetch-failed; unknown certificate verification error -- crossbrowsertesting.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- csnglobal.net: root-fetch-failed; certificate has expired -- ctmers.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- custhelp.com: root-fetch-failed; The operation was aborted. -- datocms-assets.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- details.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- docketai.com -> docket.io: root-second-target-mismatch; root landed on docket.io, second path landed on docketai.com -- dodo.ac -> nookipedia.com: deep-unrelated-final-path; root landed on non-root path /wiki/Main_Page -- dowjones.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- draftkings.com: root-fetch-failed; The operation was aborted. -- dremio.cloud: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- dropboxapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- dvla.gov.uk -> www.gov.uk: deep-unrelated-final-path; root landed on non-root path /government/organisations/driver-and-vehicle-licensing-agency -- dweet.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- easemytrip.com -> easemytrip.us: root-second-target-mismatch; root landed on easemytrip.us, second path landed on easemytrip.com -- easypdfserver.com: root-fetch-failed; certificate has expired -- ecsbr.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- edistrictodisha.gov.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- edreams.com -> edreams.net: root-second-target-mismatch; root landed on edreams.net, second path landed on edreams.com -- einstein-hub.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- elastiq.click: root-fetch-failed; The operation was aborted. -- email-templates.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- email.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- enzoreader.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- eoding.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- eos.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- epfindia.gov.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- esgenterprise.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- events.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- experiancs.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- fields.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- fieldview.com -> climate.com: deep-unrelated-final-path; root landed on non-root path /en-us.html -- finicity.com -> mastercard.com: deep-unrelated-final-path; root landed on non-root path /us/en/business/open-finance.html -- fintual.com -> fintual.cl: root-second-target-mismatch; root landed on fintual.cl, second path landed on fintual.com -- firebrowse.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- firmalyzer.com: root-fetch-failed; The operation was aborted. -- flix.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- forms.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- fos.tui: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- frankiefinancial.io: root-fetch-failed; The operation was aborted. -- freshdesk.com -> freshworks.com: deep-unrelated-final-path; root landed on non-root path /freshdesk/ -- funtranslations.com: root-fetch-failed; The operation was aborted. -- gambitcomm.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://gambitcomm.com/". For more information, pass `verbose: true` in the second argument to fetch() -- gamesparks.com -> amazon.com: deep-unrelated-final-path; root landed on non-root path /gamelift/servers/partners/ -- gamesparks.net -> amazon.com: deep-unrelated-final-path; root landed on non-root path /gamelift/servers/partners/ -- gathrd.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- getambassador.io -> gravitee.io: deep-unrelated-final-path; root landed on non-root path /gravitee-edge-stack-unified-api-visibility-and-governance -- getgo.com: root-fetch-failed; The operation was aborted. -- github.ae: root-fetch-failed; The operation was aborted. -- github.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://github.org/". For more information, pass `verbose: true` in the second argument to fetch() -- globalwinescore.com -> hugedomains.com: denylisted-target; root landed on parked/aggregator target www.hugedomains.com -- gmail.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com -- goog.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- govcon.dev -> makegov.com: root-second-target-mismatch; root landed on makegov.com, second path landed on govcon.dev -- graphql-europe.org: root-fetch-failed; The operation was aborted. -- gravit.io -> coreldraw.com: deep-unrelated-final-path; root landed on non-root path /en/product/go/ -- gteng.co: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- haloapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- healthie.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- height.app: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- hemlak.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- hemnet.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- hetras-certification.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- hetras.com -> shijigroup.com: deep-unrelated-final-path; root landed on non-root path /daylight-pms -- hetzner.cloud -> hetzner.com: deep-unrelated-final-path; root landed on non-root path /cloud -- hillbillysoftware.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- hp.gov.in: root-fetch-failed; The operation was aborted. -- hp.nic.in: root-fetch-failed; The operation was aborted. -- hpbose.org: root-fetch-failed; The operation was aborted. -- hsforms.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- hydramovies.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ib-mcp.rabi.co: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- id4i.de: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- ideal-postcodes.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- idealo.tools: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- idealspot.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- idealspot.gitlab.io -> gitlab.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): projects.gitlab.io -- idtbeyond.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- images.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- inboxroute.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- inpe.br: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- insighttimer-api.net: root-fetch-failed; unknown certificate verification error -- integration.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- intellifi.nl: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://intellifi.nl/". For more information, pass `verbose: true` in the second argument to fetch() -- intros.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- introvoke.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- intsig.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ipapas.co.kr: root-fetch-failed; certificate has expired -- iptwist.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- iqualify.com: root-fetch-failed; The operation was aborted. -- iva-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- iva-entertainment.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- izettle.com -> paypal.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): zettle.com -- javatpoint.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- jebbit.com -> blueconic.com: deep-unrelated-final-path; root landed on non-root path /experiences-by-jebbit -- jeecup.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- jira.com -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/jira -- jobs.search: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- jooble.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://jooble.com/". For more information, pass `verbose: true` in the second argument to fetch() -- jsdelivr.net: root-fetch-failed; The operation was aborted. -- just-eat.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- kakao.com -> kakaocorp.com: deep-unrelated-final-path; root landed on non-root path /page/ -- kaleyra.com -> tatacommunications.com: deep-unrelated-final-path; root landed on non-root path /kaleyra -- kar.nic.in: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- karnataka.gov.in: root-fetch-failed; The operation was aborted. -- kartikay-dhar.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- komoot.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- koomalooma.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://koomalooma.com/". For more information, pass `verbose: true` in the second argument to fetch() -- kurviger.de -> kurviger.com: root-second-target-mismatch; root landed on kurviger.com, second path landed on kurviger.de -- lacework.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lacework.com/". For more information, pass `verbose: true` in the second argument to fetch() -- lamini.ai: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- landregistry.gov.uk: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://landregistry.gov.uk/". For more information, pass `verbose: true` in the second argument to fetch() -- ldccai.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- letmc.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- lexy.gitbook.io -> gitbook.com: deep-unrelated-final-path; root landed on non-root path /o/50SIPfMR1iqghYK8hlky/s/DAjxE0NLYI3ib2scBvPQ/ -- lgtm.com -> github.blog: deep-unrelated-final-path; root landed on non-root path /news-insights/product-news/the-next-step-for-lgtm-com-github-code-scanning/ -- lifesciences.googleapis.com: root-fetch-failed; The operation was aborted. -- list-manage.com: root-fetch-failed; The operation was aborted. -- list.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- loganalytics.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- logoraisr.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- loket-acc.nl: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- looker.com -> google.com: denylisted-target; root landed on parked/aggregator target cloud.google.com -- lotadata.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- lottechem.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lottechem.com/". For more information, pass `verbose: true` in the second argument to fetch() -- lottechilsung.co.kr: root-fetch-failed; unable to verify the first certificate -- lotteglogis.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://lotteglogis.com/". For more information, pass `verbose: true` in the second argument to fetch() -- lottemembers.com: root-fetch-failed; The operation was aborted. -- lottepnd.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- lotteworld.com: root-fetch-failed; The operation was aborted. -- lummianary.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- lumminary.com: root-fetch-failed; The operation was aborted. -- lycorp.co.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- maersk.io: root-fetch-failed; The operation was aborted. -- magento.com: root-fetch-failed; The operation was aborted. -- magick.nu: root-fetch-failed; unable to verify the first certificate -- mailscript.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- maintenance.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- makemytrip.com: root-fetch-failed; The operation was aborted. -- matches.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- mcafee.com: root-fetch-failed; The operation was aborted. -- mcp.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- mediaroom.com -> prnewswire.com: deep-unrelated-final-path; root landed on non-root path /mediaroom/ -- melody.sh: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- meraki.com -> cisco.com: second-fetch-failed; The operation was aborted. -- meraki.io -> cisco.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): devnetcloud.com -- merano.eu -> merano-suedtirol.it: deep-unrelated-final-path; root landed on non-root path /de/meran.html -- messagebird.com -> bird.com: deep-unrelated-final-path; root landed on non-root path /en-us -- messages.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- metadata.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- microsoft.support: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- microsoftonline.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- migrosone.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- milvus.io: root-fetch-failed; The operation was aborted. -- mist-lab.fr: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- modal.run: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- modern-treasury.com: root-fetch-failed; The operation was aborted. -- mon-voyage-pas-cher.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- moonmoonmoonmoon.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- mozilla.com -> firefox.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): mozilla.org -- mp.gov.in: root-fetch-failed; The operation was aborted. -- mppmc.ac.in: root-fetch-failed; The operation was aborted. -- mro.name -> rohrmoser.name: root-second-target-mismatch; root landed on rohrmoser.name, second path landed on mro.name -- myaccount.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- mybluemix.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- mydomain.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- naked.insure: root-fetch-failed; The operation was aborted. -- namsor.com -> namsor.app: root-second-target-mismatch; root landed on namsor.app, second path landed on namsor.com -- nba.com: root-fetch-failed; The operation was aborted. -- nbg.gr: root-fetch-failed; The operation was aborted. -- ndhm.gov.in: root-fetch-failed; The operation was aborted. -- nebl.io: root-fetch-failed; unable to get local issuer certificate -- negd.gov.in: root-fetch-failed; The operation was aborted. -- neilpatelapi.com: root-fetch-failed; The operation was aborted. -- netatmo.com: root-fetch-failed; The operation was aborted. -- netatmo.net: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://netatmo.net/". For more information, pass `verbose: true` in the second argument to fetch() -- netbox.dev -> netboxlabs.com: deep-unrelated-final-path; root landed on non-root path /products/netbox/ -- netboxdemo.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- newsifytrends.com -> newsify.today: deep-unrelated-final-path; root landed on non-root path /english/US -- nextauth.com: root-fetch-failed; The operation was aborted. -- nexumind.com: root-fetch-failed; The operation was aborted. -- nic.at: root-fetch-failed; The operation was aborted. -- nielit.gov.in: root-fetch-failed; The operation was aborted. -- nist.gov: root-fetch-failed; The operation was aborted. -- nitap.ac.in: root-fetch-failed; The operation was aborted. -- nitp.ac.in: root-fetch-failed; The operation was aborted. -- nliblog.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- nlpcloud.io: root-fetch-failed; The operation was aborted. -- noaa.gov: root-fetch-failed; The operation was aborted. -- nomic.ai: root-fetch-failed; The operation was aborted. -- noodleseed.com: root-fetch-failed; The operation was aborted. -- nookipedia.com: root-fetch-failed; The operation was aborted. -- noosh.com: root-fetch-failed; The operation was aborted. -- nordigen.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- notion.so -> notion.com: root-second-target-mismatch; root landed on notion.com, second path landed on notion.so -- npr.org: root-fetch-failed; The operation was aborted. -- nrel.gov: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ntropy.network: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- obp.ch -> openbankingproject.ch: deep-unrelated-final-path; root landed on non-root path /en/ -- oceandrivers.com: root-fetch-failed; certificate has expired -- oktaapplications.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- olutely.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- olx.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- onfido.com -> entrust.com: deep-unrelated-final-path; root landed on non-root path /products/identity-verification -- openchannel.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- opendatanetwork.com: root-fetch-failed; The operation was aborted. -- opendatasoft.com -> huwise.com: deep-unrelated-final-path; root landed on non-root path /en/ -- openfintech.io -> payatlas.com: second-fetch-failed; The operation was aborted. -- openstates.org -> pluralpolicy.com: deep-unrelated-final-path; root landed on non-root path /open/ -- opentargets.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- opentransportdata.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- openuv.io: root-fetch-failed; The operation was aborted. -- opsgenie.com -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/opsgenie/migration -- optimade.org: root-fetch-failed; The operation was aborted. -- opto22.com: root-fetch-failed; The operation was aborted. -- organization.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- osisoft.com -> aveva.com: deep-unrelated-final-path; root landed on non-root path /en/products/aveva-pi-system/ -- oso.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ote-godaddy.com: root-fetch-failed; The operation was aborted. -- otomoto.com: root-fetch-failed; The operation was aborted. -- otomoto.pl: root-fetch-failed; The operation was aborted. -- otseek.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- outcallerai.com: root-fetch-failed; The operation was aborted. -- overstappen.nl: root-fetch-failed; The operation was aborted. -- owler.com: root-fetch-failed; The operation was aborted. -- oxforddictionaries.com -> oup.com: second-fetch-failed; The operation was aborted. -- oysterhr.com: root-fetch-failed; The operation was aborted. -- pagerduty.com: root-fetch-failed; The operation was aborted. -- palomaparties.com: root-fetch-failed; The operation was aborted. -- pandadoc.com: root-fetch-failed; The operation was aborted. -- pandascore.co: root-fetch-failed; The operation was aborted. -- pandorabots.com: root-fetch-failed; The operation was aborted. -- pane.money: root-fetch-failed; The operation was aborted. -- papayaglobal.com: root-fetch-failed; The operation was aborted. -- paperspace.com: root-fetch-failed; The operation was aborted. -- papinet.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- paragon.com: root-fetch-failed; The operation was aborted. -- parivahan.co.in: root-fetch-failed; The operation was aborted. -- parliament.uk: root-fetch-failed; The operation was aborted. -- partnerwebsite.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- passage.id: root-fetch-failed; The operation was aborted. -- passwordutility.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- patientview.org: root-fetch-failed; unknown certificate verification error -- pay1.de: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- paypi.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- payrun.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- pcraig3.ca: root-fetch-failed; The operation was aborted. -- peel-ci.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- peoplefinderspro.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- peoplegeneratorapi.live: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- petirico.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- phantauth.cf: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- phantauth.net: root-fetch-failed; certificate has expired -- phone.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- pinelabsonline.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- pixelesq.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- playdigital.com.ar: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- policynote.com: root-fetch-failed; The operation was aborted. -- poly.googleapis.com: root-fetch-failed; The operation was aborted. -- predibase.com -> rubrik.com: deep-unrelated-final-path; root landed on non-root path /products/rubrik-agent-cloud -- presalytics.io: root-fetch-failed; The operation was aborted. -- pressassociation.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- products.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- profile.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- prompts.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- proximitybeacon.googleapis.com: root-fetch-failed; The operation was aborted. -- punjabgovt.gov.in: root-fetch-failed; The operation was aborted. -- pythonboto.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- qstash.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- quarantine.country: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- quattroshapes.com: root-fetch-failed; The operation was aborted. -- quicksold.co.uk: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- quotes.rest: root-fetch-failed; The operation was aborted. -- railz.ai -> fisglobal.com: deep-unrelated-final-path; root landed on non-root path /products/accounting-data-as-a-service -- raw.githubusercontent.com -> github.com: root-second-target-mismatch; root landed on github.com, second path landed on raw.githubusercontent.com -- rawgit.com -> github.com: deep-unrelated-final-path; root landed on non-root path /rgrove/rawgit -- rbi.digital: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- rbictg.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- receptive.io -> pendo.io: deep-unrelated-final-path; root landed on non-root path /app/ -- record.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- recorrido.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://recorrido.com/". For more information, pass `verbose: true` in the second argument to fetch() -- redbus.com: root-fetch-failed; The operation was aborted. -- redeal.io: root-fetch-failed; The operation was aborted. -- redeal.se: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- refinitiv.com: root-fetch-failed; certificate has expired -- reports.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- researchsolutions.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- revenueodisha.gov.in: root-fetch-failed; The operation was aborted. -- reverb-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ritc.io -> book.gb.net: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): 1481.cn.com -- runorion.com -> bygravity.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): gravity.foundation -- rw.rw -> railsware.com: root-second-target-mismatch; root landed on railsware.com, second path landed on rw.rw -- seatpin.com: root-fetch-failed; The operation was aborted. -- segment.com -> twilio.com: deep-unrelated-final-path; root landed on non-root path /en-us/segment -- segments.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- sendgrid.com -> twilio.com: deep-unrelated-final-path; root landed on non-root path /en-us/sendgrid -- sftapi.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://sftapi.com/". For more information, pass `verbose: true` in the second argument to fetch() -- sharepoint.com -> microsoft.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): office.com -- sharing.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- sheerseo.com: root-fetch-failed; unable to verify the first certificate -- sierra.chat -> sierra.ai: root-second-target-mismatch; root landed on sierra.ai, second path landed on sierra.chat -- sketchup.com -> trimble.com: deep-unrelated-final-path; root landed on non-root path /en -- slideroom.com: root-fetch-failed; The operation was aborted. -- smart-me.com: root-fetch-failed; The operation was aborted. -- sms77.io -> seven.io: deep-unrelated-final-path; root landed on non-root path /en -- sng.link: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- solarvps.com -> hosting.com: deep-unrelated-final-path; root landed on non-root path /en-gb/hosting/vps-hosting/unmanaged/linux/ -- sonicapply.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- sparkpost.com -> bird.com: deep-unrelated-final-path; root landed on non-root path /en-us/products/email -- spdx.org -> spdx.dev: root-second-target-mismatch; root landed on spdx.dev, second path landed on spdx.org -- squarecdn.com -> squareup.com: deep-unrelated-final-path; root landed on non-root path /us/en -- staging-ecotaco.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- stainlessapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- staircase.ai -> gainsight.com: deep-unrelated-final-path; root landed on non-root path /staircase-ai/ -- staticaly.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- statuspage.io -> atlassian.com: deep-unrelated-final-path; root landed on non-root path /software/statuspage -- stream-io-api.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- stromdao.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- stubhub.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- stytch.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- subscriber.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- swaggerhub.com -> swagger.io: deep-unrelated-final-path; root landed on non-root path /product/ -- sweetspotgov.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- symanto.net: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- synq.fm: root-fetch-failed; The operation was aborted. -- tamg.cloud: root-fetch-failed; The operation was aborted. -- targetvalidation.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- tasks.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- taxamo.com -> vertexinc.com: root-second-target-mismatch; root landed on vertexinc.com, second path landed on taxamo.com -- taxrates.io -> zieweh.com: deep-unrelated-final-path; root landed on non-root path /fk -- tbse.in: root-fetch-failed; The operation was aborted. -- tempuri.org: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://tempuri.org/". For more information, pass `verbose: true` in the second argument to fetch() -- terraform.io -> hashicorp.com: deep-unrelated-final-path; root landed on non-root path /terraform -- testfire.net: root-fetch-failed; The operation was aborted. -- text2data.org: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- theysaidso.com: root-fetch-failed; The operation was aborted. -- threatjammer.com -> domino4dpoint.com: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): dominoasik.it.com -- tickets.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- tickettailor.ai: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- timetune.center: root-fetch-failed; unable to get local issuer certificate -- tldraw.workers.dev: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- tn.nic.in: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- trademeai.co.nz: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- tripura.gov.in: root-fetch-failed; The operation was aborted. -- trivago.com: root-fetch-failed; The operation was aborted. -- tubi.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- turkishairlines.com: root-fetch-failed; The operation was aborted. -- turkishtechlab.com -> turkishtechnology.com: root-second-target-mismatch; root landed on turkishtechnology.com, second path landed on turkishtechlab.com -- twinehealth.com -> healthapp.google: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): fitbit.com, fitbit.google -- tyk.com: root-fetch-failed; The operation was aborted. -- ubersuggest.com -> neilpatel.com: deep-unrelated-final-path; root landed on non-root path /ubersuggest/ -- uiic.co.in: root-fetch-failed; The operation was aborted. -- uinames.com: root-fetch-failed; certificate has expired -- uk.gov.in: root-fetch-failed; The operation was aborted. -- updatron.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- upresults.nic.in: root-fetch-failed; The operation was aborted. -- uscann.net: root-fetch-failed; The operation was aborted. -- user.read: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- vault.googleapis.com -> google.com: denylisted-target; root landed on parked/aggregator target accounts.google.com -- vectara.io: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- venturu.com: root-fetch-failed; The operation was aborted. -- versioneye.com: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://versioneye.com/". For more information, pass `verbose: true` in the second argument to fetch() -- vestorly.com -> fmgsuite.com: root-second-target-mismatch; root landed on fmgsuite.com, second path landed on vestorly.com -- victorops.com -> splunk.com: deep-unrelated-final-path; root landed on non-root path /en_us/about-splunk/acquisitions/splunk-on-call.html -- villagechief.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- vppup.in: root-fetch-failed; The operation was aborted. -- vsct.fr: root-fetch-failed; The operation was aborted. -- walmartlabs.com: root-fetch-failed; The operation was aborted. -- website-files.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- webtask.io: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://webtask.io/". For more information, pass `verbose: true` in the second argument to fetch() -- wellknown.ai -> atom.com: deep-unrelated-final-path; root landed on non-root path /lpd/name/WellKnown.ai -- wepromise.tech: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- wesang.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- whapi.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- windows.net: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://windows.net/". For more information, pass `verbose: true` in the second argument to fetch() -- woopra.com: root-fetch-failed; The operation was aborted. -- worldtimeapi.org: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- wso2apistore.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- ww.com -> weightwatchers.com: deep-unrelated-final-path; root landed on non-root path /us -- xtrf.eu -> xtm.ai: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): xtm.cloud -- yahooapis.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- yardimcp.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- yelpcdn.com: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- your-website.com: root-fetch-failed; The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the second argument to fetch() -- zappiti.com -> hugedomains.com: denylisted-target; root landed on parked/aggregator target www.hugedomains.com -- zappy.app -> zapier.com: deep-unrelated-final-path; root landed on non-root path /zappy -- zillowgroup.com -> zillow.com: deep-unrelated-final-path; root landed on non-root path /news/ -- zohomcp.com -> zoho.com: deep-unrelated-final-path; root landed on non-root path /mcp/ -- zohomcp.com.au: root-fetch-failed; ERR_TLS_CERT_ALTNAME_INVALID fetching "https://zohomcp.com.au/". For more information, pass `verbose: true` in the second argument to fetch() -- zohomcp.jp: root-fetch-failed; Unable to connect. Is the computer able to access the url? -- zuplo.work: root-fetch-failed; Unable to connect. Is the computer able to access the url? - -## Live probe evidence -- pscale.dev -> planetscale.com - - / final: https://planetscale.com/ - - / chain: 307 https://pscale.dev/ -> https://planetscale.com/ | 200 https://planetscale.com/ - - second final: https://planetscale.com/ - - second chain: 307 https://pscale.dev/.well-known/integrationsdotsh-redirect-probe -> https://planetscale.com/ | 200 https://planetscale.com/ - -## Negative guardrail evidence -- apispot.io -> bouquet-liban.tv: intermediate-unrelated-hop; redirect chain passed through unrelated domain(s): anagomez.com.mx, 7m8.org - - / final: https://bouquet-liban.tv/ -- Unit tests also cover clean apex redirect acceptance, root-only redirect rejection, denylisted target rejection, and same-registrable www/apex no-alias behavior with mocked fetch responses. - -## PlanetScale merge verification -- `canonicalDomain("pscale.dev") === "planetscale.com"`. -- Catalog files: pscale.dev=missing, planetscale.com=present. -- PlanetScale MCP URLs: https://mcp.pscale.dev/mcp/planetscale, https://mcp.pscale.dev/mcp/planetscale-insights-only. -- PlanetScale CLI packages: homebrew:planetscale/tap/pscale, github:planetscale/cli:prebuilt binaries/releases, scoop:pscale. - -## Verification -- `bun test`: passed, 47 tests, 123 assertions. -- `bun run build`: passed; build completed successfully after `bun run normalize`. -- `bun run validate:batch`: still fails on pre-existing catalog-wide `catalogMissingLocator` and unrelated duplicate-surface rows; alias-merge duplicate offenders introduced during development were fixed before build. - -## Deliberately left out -- Did not apply accepted redirect proposals whose target domain has no catalog record, matching the existing alias convention that every alias points at a canonical catalog row. -- Did not canonicalize `amazonaws.com` or `amazonaws.com.cn`; they are source-exempted because their apex redirects but subdomains under those registrable domains are independent service endpoints. diff --git a/scripts/batch/redirect-canonical.test.ts b/scripts/batch/redirect-canonical.test.ts index 23dc6819..ad193c4e 100644 --- a/scripts/batch/redirect-canonical.test.ts +++ b/scripts/batch/redirect-canonical.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import type { CatalogDomain } from "./discovered-catalog.ts"; import { probeRedirectCanonical, type FetchLike } from "./redirect-canonical.ts"; function response(url: string, status = 200, headers?: HeadersInit): Response { @@ -16,6 +17,25 @@ function mockFetch(routes: Record): FetchLike { }; } +function catalogDomain(domain: string, surfaces: CatalogDomain["surfaces"]): CatalogDomain { + return { + domain, + summary: `${domain} integration surfaces`, + surfaces, + }; +} + +function surface(name: string, url: string, spec?: string): CatalogDomain["surfaces"][number] { + return { + authStatus: "required", + name, + slug: name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""), + type: "mcp", + url, + ...(spec ? { spec } : {}), + }; +} + describe("probeRedirectCanonical", () => { test("accepts a clean domain-wide apex redirect", async () => { const decision = await probeRedirectCanonical("old.dev", { @@ -33,10 +53,10 @@ describe("probeRedirectCanonical", () => { }); test("rejects a root-only redirect when a second path stays on the source", async () => { - const decision = await probeRedirectCanonical("docs.old.dev", { + const decision = await probeRedirectCanonical("root-only.dev", { fetchImpl: mockFetch({ - "follow https://docs.old.dev/": response("https://new.com/"), - "follow https://docs.old.dev/robots.txt": response("https://docs.old.dev/robots.txt", 200), + "follow https://root-only.dev/": response("https://new.com/"), + "follow https://root-only.dev/robots.txt": response("https://root-only.dev/robots.txt", 200), }), secondPath: "/robots.txt", }); @@ -46,6 +66,66 @@ describe("probeRedirectCanonical", () => { expect(decision.reason).toBe("root-second-target-mismatch"); }); + test("rejects a subdomain source before probing cross-domain redirects", async () => { + let calls = 0; + const decision = await probeRedirectCanonical("mcp.example.dev", { + fetchImpl: async (input, init) => { + calls++; + return mockFetch({ + "follow https://mcp.example.dev/": response("https://example.com/"), + "follow https://mcp.example.dev/robots.txt": response("https://example.com/robots.txt", 404), + })(input, init); + }, + secondPath: "/robots.txt", + }); + + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") throw new Error("expected rejection"); + expect(decision.reason).toBe("subdomain-source"); + expect(calls).toBe(0); + }); + + test("rejects an apex source with a live surface URL on the apex", async () => { + let calls = 0; + const decision = await probeRedirectCanonical("old.dev", { + catalogDomains: [ + catalogDomain("old.dev", [ + surface("Old MCP server", "https://old.dev/mcp"), + ]), + ], + fetchImpl: async () => { + calls++; + throw new Error("fetch should not run after live source surface veto"); + }, + }); + + expect(decision.kind).toBe("rejected"); + if (decision.kind !== "rejected") throw new Error("expected rejection"); + expect(decision.reason).toBe("live-surfaces-on-source"); + expect(calls).toBe(0); + }); + + test("accepts an apex source whose surfaces live on a subdomain or target", async () => { + const decision = await probeRedirectCanonical("pscale.dev", { + catalogDomains: [ + catalogDomain("pscale.dev", [ + surface("PlanetScale MCP server", "https://mcp.pscale.dev/mcp/planetscale"), + surface("PlanetScale API", "https://api.planetscale.com/v1", "https://planetscale.com/docs/openapi.yaml"), + ]), + ], + fetchImpl: mockFetch({ + "follow https://pscale.dev/": response("https://planetscale.com/"), + "follow https://pscale.dev/robots.txt": response("https://planetscale.com/robots.txt", 404), + }), + secondPath: "/robots.txt", + }); + + expect(decision.kind).toBe("alias"); + if (decision.kind !== "alias") throw new Error("expected alias"); + expect(decision.source).toBe("pscale.dev"); + expect(decision.target).toBe("planetscale.com"); + }); + test("rejects parked or aggregator targets", async () => { const decision = await probeRedirectCanonical("abandoned.dev", { fetchImpl: mockFetch({ @@ -58,10 +138,10 @@ describe("probeRedirectCanonical", () => { expect(decision.reason).toBe("denylisted-target"); }); - test("does not treat same-registrable www/apex redirects as aliases", async () => { - const decision = await probeRedirectCanonical("www.example.com", { + test("does not treat same-registrable apex/www redirects as aliases", async () => { + const decision = await probeRedirectCanonical("example.dev", { fetchImpl: mockFetch({ - "follow https://www.example.com/": response("https://example.com/"), + "follow https://example.dev/": response("https://www.example.dev/"), }), }); diff --git a/scripts/batch/redirect-canonical.ts b/scripts/batch/redirect-canonical.ts index fd342066..f7dd550c 100644 --- a/scripts/batch/redirect-canonical.ts +++ b/scripts/batch/redirect-canonical.ts @@ -83,9 +83,11 @@ export type RedirectDecision = | "denylisted-target" | "intermediate-unrelated-hop" | "invalid-domain" + | "live-surfaces-on-source" | "root-second-target-mismatch" | "root-fetch-failed" | "source-exempted" + | "subdomain-source" | "second-fetch-failed"; detail: string; root?: PathProbe; @@ -100,6 +102,8 @@ export type RedirectDecision = }; export type ProbeOptions = { + catalogDir?: string; + catalogDomains?: readonly CatalogDomain[]; fetchImpl?: FetchLike; timeoutMs?: number; secondPath?: string; @@ -136,6 +140,34 @@ function hasDeepPath(url: string): boolean { } } +function locatorUrls(surface: CatalogDomain["surfaces"][number]): string[] { + return [surface.url, surface.spec].filter((value): value is string => Boolean(value)); +} + +function sourceCatalogRows(source: string, options: ProbeOptions): CatalogDomain[] { + const rows = (options.catalogDomains ?? []).filter((row) => normalizeDomain(row.domain) === source); + if (!options.catalogDir) return rows; + const path = catalogPath(options.catalogDir, source); + if (!existsSync(path)) return rows; + return [...rows, readDomainCatalogFile(path)]; +} + +function liveSurfaceLocatorsOnSource(source: string, rows: readonly CatalogDomain[]): string[] { + const locators = new Set(); + for (const row of rows) { + for (const surface of row.surfaces ?? []) { + for (const locator of locatorUrls(surface)) { + // Subdomain-hosted surfaces do not veto an apex alias: canonicalDomain() + // rewrites exact catalog keys only, so pscale.dev -> planetscale.com + // does not rewrite mcp.pscale.dev. + if (hostnameOf(locator) !== source) continue; + locators.add(locator); + } + } + } + return [...locators].sort(); +} + function chainRegistrables(chain: RedirectHop[]): string[] { const out: string[] = []; for (const hop of chain) { @@ -209,6 +241,15 @@ export async function probeRedirectCanonical(domain: string, options: ProbeOptio }; } + if (source !== sourceRegistrable) { + return { + kind: "rejected", + source, + reason: "subdomain-source", + detail: `source ${source} is a subdomain of registrable apex ${sourceRegistrable}; automatic redirect aliases are only proposed for apex domains`, + }; + } + if (SOURCE_EXEMPTIONS.has(source)) { return { kind: "rejected", @@ -218,6 +259,16 @@ export async function probeRedirectCanonical(domain: string, options: ProbeOptio }; } + const sourceSurfaceLocators = liveSurfaceLocatorsOnSource(source, sourceCatalogRows(source, options)); + if (sourceSurfaceLocators.length > 0) { + return { + kind: "rejected", + source, + reason: "live-surfaces-on-source", + detail: `catalog surface locator(s) use the source host: ${sourceSurfaceLocators.slice(0, 5).join(", ")}`, + }; + } + const probeOptions = { fetchImpl: options.fetchImpl ?? fetch, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, @@ -502,6 +553,7 @@ async function main(): Promise { if (domains.length === 0) usage(HELP); const decisions = await probeRedirectCanonicals(domains, { + catalogDir, timeoutMs: getNumberFlag(args, "timeout-ms", DEFAULT_TIMEOUT_MS), concurrency: getNumberFlag(args, "concurrency", DEFAULT_CONCURRENCY), trace: true, diff --git a/scripts/batch/sync-kv.ts b/scripts/batch/sync-kv.ts index bff2f322..3825ad53 100644 --- a/scripts/batch/sync-kv.ts +++ b/scripts/batch/sync-kv.ts @@ -215,6 +215,8 @@ async function main(): Promise { .map((domain) => domain.domain.toLowerCase()) .filter((domain) => !DOMAIN_ALIASES[domain]); const redirectDecisions = await probeRedirectCanonicals(probeDomains, { + catalogDir: outDir, + catalogDomains: incoming, timeoutMs: getNumberFlag(args, "redirect-timeout-ms", 8000), concurrency: getNumberFlag(args, "redirect-concurrency", 8), trace: true, From b3164302551e48bf0c62ea2fdf396ba560034f23 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:31:06 -0700 Subject: [PATCH 3/3] Handle redirect alias chains during sync --- scripts/batch/redirect-canonical.test.ts | 50 ++++++- scripts/batch/redirect-canonical.ts | 128 +++++++++++++++--- scripts/batch/sync-kv.test.ts | 56 ++++++++ scripts/batch/sync-kv.ts | 163 +++++++++++++++++------ 4 files changed, 335 insertions(+), 62 deletions(-) create mode 100644 scripts/batch/sync-kv.test.ts diff --git a/scripts/batch/redirect-canonical.test.ts b/scripts/batch/redirect-canonical.test.ts index ad193c4e..fa528e8c 100644 --- a/scripts/batch/redirect-canonical.test.ts +++ b/scripts/batch/redirect-canonical.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { CatalogDomain } from "./discovered-catalog.ts"; -import { probeRedirectCanonical, type FetchLike } from "./redirect-canonical.ts"; +import { probeRedirectCanonical, resolveDomainAliasUpdates, type FetchLike } from "./redirect-canonical.ts"; function response(url: string, status = 200, headers?: HeadersInit): Response { const res = new Response(null, { status, headers }); @@ -36,6 +36,54 @@ function surface(name: string, url: string, spec?: string): CatalogDomain["surfa }; } +describe("resolveDomainAliasUpdates", () => { + test("flattens existing aliases when adding a redirect from their current target", () => { + const warnings: string[] = []; + const result = resolveDomainAliasUpdates( + { "a.com": "b.com" }, + [{ source: "b.com", target: "c.com" }], + { warn: (message) => warnings.push(message) }, + ); + + expect(result.aliases).toEqual({ "a.com": "c.com", "b.com": "c.com" }); + expect(result.added).toEqual([{ source: "b.com", target: "c.com" }]); + expect(result.rewritten).toEqual([{ source: "a.com", previousTarget: "b.com", target: "c.com" }]); + expect(result.skipped).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("resolves new aliases through an existing alias source target", () => { + const warnings: string[] = []; + const result = resolveDomainAliasUpdates( + { "c.com": "d.com" }, + [{ source: "b.com", target: "c.com" }], + { warn: (message) => warnings.push(message) }, + ); + + expect(result.aliases).toEqual({ "b.com": "d.com", "c.com": "d.com" }); + expect(result.added).toEqual([{ source: "b.com", target: "d.com" }]); + expect(result.rewritten).toEqual([]); + expect(result.skipped).toEqual([]); + expect(warnings).toEqual([]); + }); + + test("skips cycle attempts without changing the alias map", () => { + const warnings: string[] = []; + const result = resolveDomainAliasUpdates( + { "c.com": "b.com" }, + [{ source: "b.com", target: "c.com" }], + { warn: (message) => warnings.push(message) }, + ); + + expect(result.aliases).toEqual({ "c.com": "b.com" }); + expect(result.added).toEqual([]); + expect(result.rewritten).toEqual([]); + expect(result.skipped).toEqual([{ source: "b.com", target: "c.com", reason: "cycle", path: ["b.com", "c.com", "b.com"] }]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("skipped alias b.com -> c.com"); + }); +}); + describe("probeRedirectCanonical", () => { test("accepts a clean domain-wide apex redirect", async () => { const decision = await probeRedirectCanonical("old.dev", { diff --git a/scripts/batch/redirect-canonical.ts b/scripts/batch/redirect-canonical.ts index f7dd550c..ae285cb6 100644 --- a/scripts/batch/redirect-canonical.ts +++ b/scripts/batch/redirect-canonical.ts @@ -396,6 +396,95 @@ export async function probeRedirectCanonicals(domains: string[], options: ProbeO return mapLimit(unique, concurrency, (domain) => probeRedirectCanonical(domain, options)); } +export type DomainAlias = { source: string; target: string }; + +export type DomainAliasTargetResolution = + | { kind: "target"; target: string; path: string[] } + | { kind: "cycle"; path: string[] }; + +export type DomainAliasUpdateResult = { + aliases: Record; + added: DomainAlias[]; + rewritten: Array<{ source: string; previousTarget: string; target: string }>; + skipped: Array<{ source: string; target: string; reason: "cycle"; path: string[] }>; + applied: DomainAlias[]; +}; + +export type DomainAliasFileUpdateResult = Omit & { + changed: boolean; +}; + +export function resolveDomainAliasTarget(source: string, target: string, aliases: Record): DomainAliasTargetResolution { + const normalizedSource = normalizeDomain(source); + let current = normalizeDomain(target); + const path = [normalizedSource]; + const seen = new Set(path); + + while (current) { + path.push(current); + if (seen.has(current)) return { kind: "cycle", path }; + seen.add(current); + const next = aliases[current]; + if (!next) return { kind: "target", target: current, path }; + current = normalizeDomain(next); + } + + return { kind: "cycle", path }; +} + +function warnSkippedAlias(skip: DomainAliasUpdateResult["skipped"][number], warn: (message: string) => void): void { + warn(`redirect-probe: skipped alias ${skip.source} -> ${skip.target}: adding it would create an alias cycle (${skip.path.join(" -> ")})`); +} + +export function resolveDomainAliasUpdates( + current: Record, + aliases: readonly DomainAlias[], + options: { warn?: (message: string) => void } = {}, +): DomainAliasUpdateResult { + const next = { ...current }; + const added: DomainAlias[] = []; + const rewritten: DomainAliasUpdateResult["rewritten"] = []; + const skipped: DomainAliasUpdateResult["skipped"] = []; + const warn = options.warn ?? console.warn; + + for (const alias of aliases) { + const source = normalizeDomain(alias.source); + const requestedTarget = normalizeDomain(alias.target); + if (!source || !requestedTarget) continue; + + const resolution = resolveDomainAliasTarget(source, requestedTarget, next); + if (resolution.kind === "cycle") { + const skip = { source, target: requestedTarget, reason: "cycle" as const, path: resolution.path }; + skipped.push(skip); + warnSkippedAlias(skip, warn); + continue; + } + + const target = resolution.target; + const previousTarget = next[source]; + if (previousTarget === target) continue; + + next[source] = target; + if (previousTarget) { + rewritten.push({ source, previousTarget, target }); + } else { + added.push({ source, target }); + } + + for (const [otherSource, otherTarget] of Object.entries(next)) { + if (otherSource === source || otherTarget !== source) continue; + next[otherSource] = target; + rewritten.push({ source: otherSource, previousTarget: otherTarget, target }); + } + } + + const applied = [ + ...rewritten.map(({ source, target }) => ({ source, target })), + ...added, + ]; + return { aliases: next, added, rewritten, skipped, applied }; +} + function parseAliasEntries(source: string): Record { const match = source.match(/export const DOMAIN_ALIASES: Record = \{\n([\s\S]*?)\n\};/); if (!match) throw new Error("Could not find DOMAIN_ALIASES object"); @@ -408,32 +497,25 @@ function parseAliasEntries(source: string): Record { } export function upsertDomainAliasesFile( - aliases: readonly { source: string; target: string }[], + aliases: readonly DomainAlias[], aliasPath = join(ROOT, "src", "lib", "domain-aliases.ts"), -): { changed: boolean; added: Array<{ source: string; target: string }> } { +): DomainAliasFileUpdateResult { const before = readFileSync(aliasPath, "utf8"); const current = parseAliasEntries(before); - const next = { ...current }; - const added: Array<{ source: string; target: string }> = []; + const update = resolveDomainAliasUpdates(current, aliases); + const { aliases: _aliases, ...result } = update; - for (const alias of aliases) { - const source = normalizeDomain(alias.source); - let target = normalizeDomain(alias.target); - while (next[target]) target = next[target]!; - if (!source || !target || source === target) continue; - if (next[source] === target) continue; - next[source] = target; - added.push({ source, target }); + if (update.added.length === 0 && update.rewritten.length === 0) { + return { changed: false, ...result }; } - if (added.length === 0) return { changed: false, added }; - for (const [source, target] of Object.entries(next)) { - if (!source || !target || source === target || next[target]) { + for (const [source, target] of Object.entries(update.aliases)) { + if (!source || !target || source === target || update.aliases[target]) { throw new Error(`Invalid domain alias after update: ${source} -> ${target}`); } } - const body = Object.entries(next) + const body = Object.entries(update.aliases) .sort(([a], [b]) => a.localeCompare(b)) .map(([source, target]) => ` "${source}": "${target}",`) .join("\n"); @@ -442,7 +524,7 @@ export function upsertDomainAliasesFile( `export const DOMAIN_ALIASES: Record = {\n${body}\n};`, ); if (after !== before) writeFileSync(aliasPath, after); - return { changed: after !== before, added }; + return { changed: after !== before, ...result }; } function catalogPath(root: string, domain: string): string { @@ -577,12 +659,18 @@ async function main(): Promise { const acceptedAliases = accepted.map((decision) => ({ source: decision.source, target: decision.target })); const applyMissingTargets = hasFlag(args, "apply-missing-targets"); + const aliasPath = join(ROOT, "src", "lib", "domain-aliases.ts"); + const plannedAliasWrite = resolveDomainAliasUpdates(parseAliasEntries(readFileSync(aliasPath, "utf8")), acceptedAliases); + const plannedTargetBySource = new Map(plannedAliasWrite.applied.map((alias) => [alias.source, alias.target])); const newAliases = applyMissingTargets ? acceptedAliases - : acceptedAliases.filter((alias) => hasCatalogRecord(catalogDir, alias.target)); + : acceptedAliases.filter((alias) => { + const target = plannedTargetBySource.get(normalizeDomain(alias.source)); + return target ? hasCatalogRecord(catalogDir, target) : false; + }); const skippedMissingTargets = acceptedAliases.filter((alias) => !newAliases.some((applied) => applied.source === alias.source)); - const aliasWrite = upsertDomainAliasesFile(newAliases); - const mergeResults = mergeAliasCatalogFiles(catalogDir, newAliases); + const aliasWrite = upsertDomainAliasesFile(newAliases, aliasPath); + const mergeResults = mergeAliasCatalogFiles(catalogDir, aliasWrite.applied); if (!hasFlag(args, "json")) { console.log(`aliases file changed=${aliasWrite.changed} added=${aliasWrite.added.length}`); for (const alias of skippedMissingTargets) { diff --git a/scripts/batch/sync-kv.test.ts b/scripts/batch/sync-kv.test.ts new file mode 100644 index 00000000..29e50f48 --- /dev/null +++ b/scripts/batch/sync-kv.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { CatalogDomain } from "./discovered-catalog.ts"; +import { writeSyncedCatalog } from "./sync-kv.ts"; + +function domain(domainName: string): CatalogDomain { + return { + discoveredAt: "2026-07-03T00:00:00.000Z", + domain: domainName, + summary: `${domainName} integration surfaces`, + surfaces: [ + { + authStatus: "unknown", + name: "API", + slug: "api", + type: "http", + url: `https://${domainName}/api`, + }, + ], + }; +} + +describe("writeSyncedCatalog", () => { + test("continues writing catalog files when redirect alias write fails", () => { + const dir = mkdtempSync(join(tmpdir(), "sync-kv-alias-failure-")); + const logs: string[] = []; + const warnings: string[] = []; + + try { + const result = writeSyncedCatalog({ + acceptedAliases: [{ source: "b.com", target: "c.com" }], + incoming: [domain("b.com")], + keysListed: 1, + log: (message) => logs.push(message), + outDir: dir, + skippedInvalid: 0, + warn: (message) => warnings.push(message), + writeAliases: () => { + throw new Error("injected alias validation failure"); + }, + }); + + const catalogPath = join(dir, "b.com", "integrations.json"); + expect(result.aliasWriteError?.message).toBe("injected alias validation failure"); + expect(result.written).toEqual({ written: 1, changed: 1, skipped: [] }); + expect(existsSync(catalogPath)).toBe(true); + expect(JSON.parse(readFileSync(catalogPath, "utf8")).domain).toBe("b.com"); + expect(warnings.join("\n")).toContain("failed to write aliases for b.com -> c.com"); + expect(logs.some((message) => message.includes("wrote 1 domain files"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/scripts/batch/sync-kv.ts b/scripts/batch/sync-kv.ts index 3825ad53..4e2a0080 100644 --- a/scripts/batch/sync-kv.ts +++ b/scripts/batch/sync-kv.ts @@ -13,7 +13,15 @@ import { writeDomainCatalogTree, } from "./discovered-catalog.ts"; import { DOMAIN_ALIASES } from "../../src/lib/domain-aliases.ts"; -import { mergeAliasCatalogFiles, probeRedirectCanonicals, upsertDomainAliasesFile } from "./redirect-canonical.ts"; +import { + mergeAliasCatalogFiles, + probeRedirectCanonicals, + resolveDomainAliasTarget, + resolveDomainAliasUpdates, + upsertDomainAliasesFile, + type DomainAlias, + type DomainAliasFileUpdateResult, +} from "./redirect-canonical.ts"; import { getFlag, getNumberFlag, hasFlag, parseArgs, ROOT, usage } from "./shared.ts"; const DISCOVERY_NAMESPACE_ID = "7456151d9722471ca000f6d3b03a62c7"; @@ -162,6 +170,107 @@ export function mergeDiscoveredCatalog(existing: Catalog, incoming: readonly Cat return mergeCatalogs(existing, incoming, options); } +type SyncCatalogOptions = { + acceptedAliases?: readonly DomainAlias[]; + dryRun?: boolean; + incoming: readonly CatalogDomain[]; + keysListed: number; + log?: (message: string) => void; + mergeAliases?: typeof mergeAliasCatalogFiles; + outDir: string; + skippedInvalid: number; + warn?: (message: string) => void; + writeAliases?: (aliases: readonly DomainAlias[]) => DomainAliasFileUpdateResult; +}; + +export type SyncCatalogResult = { + aliasWrite?: DomainAliasFileUpdateResult; + aliasWriteError?: Error; + mergeResults: ReturnType; + sampleDiff?: string; + summary: SyncSummary; + written?: ReturnType; +}; + +function aliasList(aliases: readonly DomainAlias[]): string { + return aliases.map((alias) => `${alias.source} -> ${alias.target}`).join(", "); +} + +function aliasOverrides(aliases: readonly DomainAlias[]): Record { + return Object.fromEntries(aliases.map((alias) => [alias.source, alias.target])); +} + +export function writeSyncedCatalog(options: SyncCatalogOptions): SyncCatalogResult { + const acceptedAliases = options.acceptedAliases ?? []; + const dryRun = options.dryRun ?? false; + const log = options.log ?? console.log; + const warn = options.warn ?? console.warn; + const writeAliases = options.writeAliases ?? upsertDomainAliasesFile; + const mergeAliases = options.mergeAliases ?? mergeAliasCatalogFiles; + + let aliasWrite: DomainAliasFileUpdateResult | undefined; + let aliasWriteError: Error | undefined; + let appliedAliases: DomainAlias[] = []; + + if (acceptedAliases.length > 0) { + if (dryRun) { + appliedAliases = resolveDomainAliasUpdates(DOMAIN_ALIASES, acceptedAliases, { warn }).applied; + } else { + try { + aliasWrite = writeAliases(acceptedAliases); + appliedAliases = aliasWrite.applied; + log(`redirect-probe: aliases file changed=${aliasWrite.changed} added=${aliasWrite.added.length}`); + } catch (err) { + aliasWriteError = err as Error; + warn(`redirect-probe: failed to write aliases for ${aliasList(acceptedAliases)}: ${aliasWriteError.message}`); + warn("redirect-probe: continuing catalog sync without applying new redirect aliases; run redirect:canonical --apply after fixing aliases."); + } + } + } + + const catalogOptions = { aliases: aliasMapWith(aliasOverrides(appliedAliases)) }; + const existing = readDomainCatalogTree(options.outDir, catalogOptions); + const merged = mergeDiscoveredCatalog(existing, options.incoming, catalogOptions); + const summary: SyncSummary = { + keysListed: options.keysListed, + skippedInvalid: options.skippedInvalid, + parsed: options.incoming.length, + mergedNew: merged.stats.new, + updated: merged.stats.updated, + unchanged: merged.stats.unchanged, + }; + + log( + [ + `keys listed: ${summary.keysListed}`, + `skipped invalid: ${summary.skippedInvalid}`, + `parsed: ${summary.parsed}`, + `domain files added: ${summary.mergedNew}`, + `domain files updated: ${summary.updated}`, + `domain files unchanged: ${summary.unchanged}`, + ].join("\n"), + ); + + if (dryRun) { + const diff = sampleDiff(merged.changes); + log(diff); + return { aliasWrite, aliasWriteError, mergeResults: [], sampleDiff: diff, summary }; + } + + const written = writeDomainCatalogTree(options.outDir, merged.catalog.domains, catalogOptions); + for (const skip of written.skipped) { + warn(`sync-kv: skipped ${skip.domain}: ${skip.reason}`); + } + + const mergeResults = aliasWriteError ? [] : mergeAliases(options.outDir, appliedAliases); + for (const result of mergeResults) { + log(`redirect-probe: catalog ${result.action} ${result.source} -> ${result.target}`); + } + log(`wrote ${written.written} domain files to ${options.outDir} (${written.changed} changed)`); + + return { aliasWrite, aliasWriteError, mergeResults, summary, written }; +} + async function main(): Promise { const args = parseArgs(); if (hasFlag(args, "help")) usage(HELP); @@ -208,7 +317,11 @@ async function main(): Promise { } const incomingTargets = new Set(incoming.map((domain) => catalogDomainKey(domain.domain)).filter((domain): domain is string => Boolean(domain))); - const hasAliasTarget = (target: string): boolean => incomingTargets.has(target) || existsSync(join(outDir, target, "integrations.json")); + const hasAliasTarget = (source: string, target: string): boolean => { + const resolution = resolveDomainAliasTarget(source, target, DOMAIN_ALIASES); + const candidates = resolution.kind === "target" ? [resolution.target] : [source, target]; + return candidates.some((candidate) => incomingTargets.has(candidate) || existsSync(join(outDir, candidate, "integrations.json"))); + }; const acceptedAliases: Array<{ source: string; target: string }> = []; if (!hasFlag(args, "skip-redirect-probe")) { const probeDomains = incoming @@ -224,7 +337,7 @@ async function main(): Promise { for (const decision of redirectDecisions) { if (decision.kind === "alias") { console.log(`redirect-probe: accepted ${decision.source} -> ${decision.target}`); - if (hasAliasTarget(decision.target)) { + if (hasAliasTarget(decision.source, decision.target)) { acceptedAliases.push({ source: decision.source, target: decision.target }); } else { console.log(`redirect-probe: skipped ${decision.source} -> ${decision.target}: target catalog record not found`); @@ -233,48 +346,16 @@ async function main(): Promise { console.log(`redirect-probe: rejected ${decision.source}${decision.target ? ` -> ${decision.target}` : ""} (${decision.reason}): ${decision.detail}`); } } - if (acceptedAliases.length > 0 && !dryRun) { - const aliasWrite = upsertDomainAliasesFile(acceptedAliases); - console.log(`redirect-probe: aliases file changed=${aliasWrite.changed} added=${aliasWrite.added.length}`); - } } - const catalogOptions = { aliases: aliasMapWith(Object.fromEntries(acceptedAliases.map((alias) => [alias.source, alias.target]))) }; - const existing = readDomainCatalogTree(outDir, catalogOptions); - const merged = mergeDiscoveredCatalog(existing, incoming, catalogOptions); - const summary: SyncSummary = { + writeSyncedCatalog({ + acceptedAliases, + dryRun, + incoming, keysListed: keys.length, + outDir, skippedInvalid, - parsed: incoming.length, - mergedNew: merged.stats.new, - updated: merged.stats.updated, - unchanged: merged.stats.unchanged, - }; - - console.log( - [ - `keys listed: ${summary.keysListed}`, - `skipped invalid: ${summary.skippedInvalid}`, - `parsed: ${summary.parsed}`, - `domain files added: ${summary.mergedNew}`, - `domain files updated: ${summary.updated}`, - `domain files unchanged: ${summary.unchanged}`, - ].join("\n"), - ); - - if (dryRun) { - console.log(sampleDiff(merged.changes)); - return; - } - - const written = writeDomainCatalogTree(outDir, merged.catalog.domains, catalogOptions); - for (const skip of written.skipped) { - console.warn(`sync-kv: skipped ${skip.domain}: ${skip.reason}`); - } - for (const result of mergeAliasCatalogFiles(outDir, acceptedAliases)) { - console.log(`redirect-probe: catalog ${result.action} ${result.source} -> ${result.target}`); - } - console.log(`wrote ${written.written} domain files to ${outDir} (${written.changed} changed)`); + }); } if (import.meta.main) await main();