diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
index d6af3c18e..654377d76 100644
--- a/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
+++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessReviewSourceRow.tsx
@@ -116,6 +116,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string {
return "Metabase";
case "SIGNOZ":
return "SigNoz";
+ case "CURSOR":
+ return "Cursor";
default:
return connectorProvider;
}
@@ -231,6 +233,7 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
};
const showOrgSelector = accessSource.needsConfiguration || accessSource.selectedOrganization;
+ const canReconnect = (accessSource.connector?.oauth2Scopes.length ?? 0) > 0;
return (
@@ -246,10 +249,14 @@ export function AccessReviewSourceRow({ fKey, connectionId, organizationId }: Pr
)}
{accessSource.connectionStatus === "DISCONNECTED" && (
- {__("Disconnected")}
-
+
+ {canReconnect ? __("Disconnected") : __("Invalid credentials")}
+
+ {canReconnect && (
+
+ )}
)}
diff --git a/pkg/connector/provider/anthropic.go b/pkg/connector/provider/anthropic.go
index c599e4736..0c63b8558 100644
--- a/pkg/connector/provider/anthropic.go
+++ b/pkg/connector/provider/anthropic.go
@@ -35,10 +35,7 @@ func anthropicRegistration() *Registration {
// third-party OAuth2 flow for the Admin API, so this is API-key
// only and takes a single admin key (sk-ant-admin...) per org.
APIKeyHeader: "x-api-key",
- // ProbeURL is intentionally empty: the generic probe issues a
- // header-less GET that cannot send the required anthropic-version
- // header, so it would 400 regardless of token validity. A dead
- // key surfaces on the first ListAccounts instead.
+ Probe: probeAnthropic,
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewAnthropicDriver(c), nil
},
diff --git a/pkg/connector/provider/clerk.go b/pkg/connector/provider/clerk.go
index 680443b7c..c07b5fc25 100644
--- a/pkg/connector/provider/clerk.go
+++ b/pkg/connector/provider/clerk.go
@@ -36,11 +36,7 @@ func clerkRegistration() *Registration {
// API. The secret key is bound to one Clerk instance, so there is
// nothing to pick (Pattern 3): no settings struct, no picker, no
// SetOrganizationSettings.
- //
- // ProbeURL is intentionally empty: the connection probe runs only
- // for OAuth2 connections, so it would be dead config for an API-key
- // provider; a dead key surfaces on the first ListAccounts instead.
- //
+ ProbeURL: "https://api.clerk.com/v1/users?limit=1",
// No NewNameResolver: the Backend API exposes no instance/application
// name endpoint reachable with a secret key, so the source keeps its
// generic name (the source-name worker degrades gracefully).
diff --git a/pkg/connector/provider/cursor.go b/pkg/connector/provider/cursor.go
index 9c664bdd8..dc131be49 100644
--- a/pkg/connector/provider/cursor.go
+++ b/pkg/connector/provider/cursor.go
@@ -23,6 +23,8 @@ import (
"go.probo.inc/probo/pkg/coredata"
)
+const cursorMembersEndpoint = "https://api.cursor.com/teams/members"
+
func cursorRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderCursor,
@@ -36,11 +38,7 @@ func cursorRegistration() *Registration {
// there is nothing to pick (Pattern 3): no settings struct, no
// picker, and no SetOrganizationSettings.
APIKeyBasicAuth: true,
- // ProbeURL is intentionally empty. API-key connectors skip the
- // connection probe entirely (it runs only for OAuth2), so a probe
- // URL would be dead config; a dead key surfaces on the first
- // ListAccounts instead.
- //
+ ProbeURL: cursorMembersEndpoint,
// No NewNameResolver: the Admin API exposes no team/organization
// name endpoint, so the source keeps its generic name (the
// source-name worker degrades gracefully when no resolver is set).
diff --git a/pkg/connector/provider/datadog.go b/pkg/connector/provider/datadog.go
index b13404a1e..84f2c5b98 100644
--- a/pkg/connector/provider/datadog.go
+++ b/pkg/connector/provider/datadog.go
@@ -28,10 +28,9 @@ import (
func datadogRegistration() *Registration {
// Datadog is multi-site: the customer's region drives the authorize
// host (built at initiate from the region pick) and the token + API
- // host (built at callback from Datadog's `domain` param). AuthURL,
- // TokenURL, and ProbeURL are therefore empty — the closures build the
- // per-customer hosts, and an empty probe is skipped (a dead token
- // surfaces on the first ListAccounts). Confidential client + PKCE map
+ // host (built at callback from Datadog's `domain` param). AuthURL and
+ // TokenURL are empty — the closures build the per-customer hosts.
+ // BuildProbeURL targets the stored API domain. Confidential client + PKCE map
// to the default post-form token-endpoint auth.
return &Registration{
Provider: coredata.ConnectorProviderDatadog,
@@ -40,6 +39,7 @@ func datadogRegistration() *Registration {
RequiresPKCE: true,
BuildAuthURLForSite: connector.DatadogAuthorizeURL,
BuildTokenURLForDomain: connector.DatadogTokenURL,
+ BuildProbeURL: buildDatadogProbeURL,
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
s, err := coredata.ConnectorSettings[coredata.DatadogConnectorSettings](conn)
if err != nil {
diff --git a/pkg/connector/provider/grafana.go b/pkg/connector/provider/grafana.go
index 29ef07b05..9381ec25d 100644
--- a/pkg/connector/provider/grafana.go
+++ b/pkg/connector/provider/grafana.go
@@ -31,6 +31,7 @@ func grafanaRegistration() *Registration {
Provider: coredata.ConnectorProviderGrafana,
DisplayName: "Grafana",
SupportsAPIKey: true,
+ BuildProbeURL: buildGrafanaProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "baseUrl", Label: "Base URL", Required: true},
},
diff --git a/pkg/connector/provider/linear.go b/pkg/connector/provider/linear.go
index f32422222..dc44735c4 100644
--- a/pkg/connector/provider/linear.go
+++ b/pkg/connector/provider/linear.go
@@ -29,7 +29,7 @@ func linearRegistration() *Registration {
DisplayName: "Linear",
AuthURL: "https://linear.app/oauth/authorize",
TokenURL: "https://api.linear.app/oauth/token",
- ProbeURL: "https://api.linear.app/graphql",
+ Probe: probeLinear,
OAuth2Scopes: []string{"read"},
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewLinearDriver(c), nil
diff --git a/pkg/connector/provider/metabase.go b/pkg/connector/provider/metabase.go
index 6ab6fee0f..0de97939f 100644
--- a/pkg/connector/provider/metabase.go
+++ b/pkg/connector/provider/metabase.go
@@ -32,6 +32,7 @@ func metabaseRegistration() *Registration {
DisplayName: "Metabase",
SupportsAPIKey: true,
APIKeyHeader: "x-api-key",
+ BuildProbeURL: buildMetabaseProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "instanceUrl", Label: "Instance URL", Required: true},
},
diff --git a/pkg/connector/provider/monday.go b/pkg/connector/provider/monday.go
index 9f1b50acb..1aa05845f 100644
--- a/pkg/connector/provider/monday.go
+++ b/pkg/connector/provider/monday.go
@@ -24,15 +24,12 @@ import (
)
func mondayRegistration() *Registration {
- // Monday.com's primary API is GraphQL POST, and the auth subdomain
- // does not expose a Bearer-protected GET userinfo endpoint, so
- // ProbeURL is empty. The probe handler skips empty entries; an
- // invalid token surfaces at the next /v2 query.
return &Registration{
Provider: coredata.ConnectorProviderMonday,
DisplayName: "Monday.com",
AuthURL: "https://auth.monday.com/oauth2/authorize",
TokenURL: "https://auth.monday.com/oauth2/token",
+ Probe: probeMonday,
OAuth2Scopes: []string{"users:read", "account:read"},
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewMondayDriver(c), nil
diff --git a/pkg/connector/provider/neon.go b/pkg/connector/provider/neon.go
index a3c84c3ae..1e9aa2717 100644
--- a/pkg/connector/provider/neon.go
+++ b/pkg/connector/provider/neon.go
@@ -35,10 +35,8 @@ func neonRegistration() *Registration {
// can belong to several organizations; the operator supplies the
// org ID (org-...) of the one to review.
//
- // ProbeURL is intentionally empty: the connection probe runs only
- // for OAuth2 connections, so it would be dead config for an
- // API-key provider; a dead key surfaces on the first ListAccounts.
SupportsAPIKey: true,
+ BuildProbeURL: buildNeonProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "organizationId", Label: "Organization ID", Required: true},
},
diff --git a/pkg/connector/provider/okta.go b/pkg/connector/provider/okta.go
index 3e88c2622..6f0cc263e 100644
--- a/pkg/connector/provider/okta.go
+++ b/pkg/connector/provider/okta.go
@@ -30,15 +30,14 @@ import (
// possible — it authenticates with a read-only API token presented under the
// `SSWS` Authorization scheme (APIKeyAuthScheme), plus the customer's org
// domain. The token + domain identify exactly one org, so there is no picker
-// and no OAuth metadata. ProbeURL is empty because the API host is per-org and
-// there is no static URL to probe; a dead token surfaces on the first
-// ListAccounts.
+// and no OAuth metadata. BuildProbeURL targets the org's own API host.
func oktaRegistration() *Registration {
return &Registration{
Provider: coredata.ConnectorProviderOkta,
DisplayName: "Okta",
SupportsAPIKey: true,
APIKeyAuthScheme: "SSWS",
+ BuildProbeURL: buildOktaProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "domain", Label: "Okta Domain", Required: true},
},
diff --git a/pkg/connector/provider/posthog.go b/pkg/connector/provider/posthog.go
index 88db8b50f..84de4a015 100644
--- a/pkg/connector/provider/posthog.go
+++ b/pkg/connector/provider/posthog.go
@@ -52,12 +52,8 @@ func posthogRegistration() *Registration {
// organization_member:read applies org-wide and the org endpoints
// resolve @current to the granted organization.
ExtraAuthParams: map[string]string{"required_access_level": "organization"},
- // ProbeURL is intentionally empty: the data host varies per
- // connection (the region-agnostic gateway for OAuth, us/eu for
- // API-key), so a single static probe URL cannot match it. A dead
- // token surfaces on the first ListAccounts.
-
- SupportsAPIKey: true,
+ Probe: probePostHog,
+ SupportsAPIKey: true,
// API-key connections are either PostHog Cloud (a region, us/eu) or
// self-hosted (an instance URL). The two are mutually exclusive, so
// neither is individually Required; apiKeyConnectorSettings enforces
diff --git a/pkg/connector/provider/probe.go b/pkg/connector/provider/probe.go
new file mode 100644
index 000000000..537e08506
--- /dev/null
+++ b/pkg/connector/provider/probe.go
@@ -0,0 +1,444 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "go.probo.inc/probo/pkg/connector"
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+const (
+ anthropicAPIVersion = "2023-06-01"
+ anthropicUsersProbeURL = "https://api.anthropic.com/v1/organizations/users?limit=1"
+ linearGraphQLEndpoint = "https://api.linear.app/graphql"
+ mondayGraphQLEndpoint = "https://api.monday.com/v2"
+ posthogOrganizationPath = "/api/organizations/@current/"
+ posthogUSBaseURL = "https://us.posthog.com"
+ posthogEUBaseURL = "https://eu.posthog.com"
+)
+
+// ProbeConnection verifies that the connector credential is accepted by the
+// provider. It dispatches to a provider-specific Probe closure when
+// registered, otherwise issues a lightweight GET against ProbeURL or
+// BuildProbeURL. An empty probe URL means the check is skipped.
+func (r *Registry) ProbeConnection(
+ ctx context.Context,
+ httpClient *http.Client,
+ conn *coredata.Connector,
+) error {
+ reg, ok := r.Get(conn.Provider)
+ if !ok {
+ return nil
+ }
+
+ if reg.Probe != nil {
+ return reg.Probe(ctx, httpClient, conn)
+ }
+
+ probeURL := reg.ProbeURL
+ if reg.BuildProbeURL != nil {
+ built, err := reg.BuildProbeURL(conn)
+ if err != nil {
+ return fmt.Errorf("cannot build probe URL: %w", err)
+ }
+
+ probeURL = built
+ }
+
+ return probeGET(ctx, httpClient, probeURL)
+}
+
+func probeGET(ctx context.Context, httpClient *http.Client, probeURL string) error {
+ if probeURL == "" {
+ return nil
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
+ if err != nil {
+ return fmt.Errorf("cannot create probe request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ return doProbeRequest(httpClient, req)
+}
+
+func probePOSTJSON(
+ ctx context.Context,
+ httpClient *http.Client,
+ probeURL string,
+ payload any,
+ extraHeaders map[string]string,
+) error {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("cannot marshal probe request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, probeURL, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("cannot create probe request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("Content-Type", "application/json")
+
+ for key, value := range extraHeaders {
+ req.Header.Set(key, value)
+ }
+
+ return doProbeRequest(httpClient, req)
+}
+
+func doProbeRequest(httpClient *http.Client, req *http.Request) error {
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("probe request failed: %w", err)
+ }
+
+ defer func() {
+ _, _ = io.Copy(io.Discard, resp.Body)
+ _ = resp.Body.Close()
+ }()
+
+ if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
+ return fmt.Errorf("credential rejected: status %d", resp.StatusCode)
+ }
+
+ return nil
+}
+
+func buildDatadogProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.DatadogConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read datadog connector settings: %w", err)
+ }
+
+ if !connector.IsValidDatadogDomain(s.Domain) {
+ return "", fmt.Errorf("invalid or missing datadog domain")
+ }
+
+ q := url.Values{}
+ q.Set("page[size]", "1")
+ q.Set("page[number]", "0")
+
+ endpoint := url.URL{
+ Scheme: "https",
+ Host: "api." + s.Domain,
+ Path: "/api/v2/users",
+ RawQuery: q.Encode(),
+ }
+
+ return endpoint.String(), nil
+}
+
+func buildZendeskProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read zendesk connector settings: %w", err)
+ }
+
+ if !connector.IsValidZendeskSubdomain(s.Subdomain) {
+ return "", fmt.Errorf("invalid or missing zendesk subdomain")
+ }
+
+ q := url.Values{}
+ q.Set("page[size]", "1")
+ q.Add("role[]", "agent")
+ q.Add("role[]", "admin")
+
+ endpoint := url.URL{
+ Scheme: "https",
+ Host: s.Subdomain + ".zendesk.com",
+ Path: "/api/v2/users.json",
+ RawQuery: q.Encode(),
+ }
+
+ return endpoint.String(), nil
+}
+
+func buildOktaProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.OktaConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read okta connector settings: %w", err)
+ }
+
+ if !connector.IsValidOktaDomain(s.Domain) {
+ return "", fmt.Errorf("invalid or missing okta domain")
+ }
+
+ endpoint := url.URL{
+ Scheme: "https",
+ Host: s.Domain,
+ Path: "/api/v1/users",
+ RawQuery: url.Values{"limit": {"1"}}.Encode(),
+ }
+
+ return endpoint.String(), nil
+}
+
+func buildNeonProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.NeonConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read neon connector settings: %w", err)
+ }
+
+ if s.OrganizationID == "" {
+ return "", fmt.Errorf("missing neon organization_id")
+ }
+
+ endpoint, err := url.JoinPath(
+ "https://console.neon.tech/api/v2",
+ "organizations",
+ url.PathEscape(s.OrganizationID),
+ "members",
+ )
+ if err != nil {
+ return "", fmt.Errorf("cannot build neon probe URL: %w", err)
+ }
+
+ q := url.Values{"limit": {"1"}}
+
+ return endpoint + "?" + q.Encode(), nil
+}
+
+func buildRenderProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.RenderConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read render connector settings: %w", err)
+ }
+
+ if s.OwnerID == "" {
+ return "", fmt.Errorf("missing render owner_id")
+ }
+
+ return url.JoinPath(
+ "https://api.render.com/v1",
+ "owners",
+ url.PathEscape(s.OwnerID),
+ "members",
+ )
+}
+
+func buildQoveryProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.QoveryConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read qovery connector settings: %w", err)
+ }
+
+ if s.OrganizationID == "" {
+ return "", fmt.Errorf("missing qovery organization_id")
+ }
+
+ return url.JoinPath(
+ "https://api.qovery.com",
+ "organization",
+ url.PathEscape(s.OrganizationID),
+ "member",
+ )
+}
+
+func buildGrafanaProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.GrafanaConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read grafana connector settings: %w", err)
+ }
+
+ baseURL, err := normalizeGrafanaBaseURL(s.BaseURL)
+ if err != nil {
+ return "", err
+ }
+
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse grafana base URL: %w", err)
+ }
+
+ u = u.JoinPath("api", "org", "users")
+ q := u.Query()
+ q.Set("perpage", "1")
+ q.Set("page", "1")
+ u.RawQuery = q.Encode()
+
+ return u.String(), nil
+}
+
+func buildMetabaseProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.MetabaseConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read metabase connector settings: %w", err)
+ }
+
+ instanceURL := strings.TrimSpace(s.InstanceURL)
+ if instanceURL == "" {
+ return "", fmt.Errorf("missing metabase instance_url")
+ }
+
+ if err := validateMetabaseInstanceURL(instanceURL); err != nil {
+ return "", err
+ }
+
+ u, err := url.Parse(instanceURL)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse metabase instance URL: %w", err)
+ }
+
+ endpoint := u.JoinPath("api", "user")
+ q := endpoint.Query()
+ q.Set("status", "all")
+ q.Set("limit", "1")
+ q.Set("offset", "0")
+ endpoint.RawQuery = q.Encode()
+
+ return endpoint.String(), nil
+}
+
+func buildSigNozProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.SigNozConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read signoz connector settings: %w", err)
+ }
+
+ baseURL, err := normalizeSigNozBaseURL(s.BaseURL)
+ if err != nil {
+ return "", err
+ }
+
+ u, err := url.Parse(baseURL)
+ if err != nil {
+ return "", fmt.Errorf("cannot parse signoz base URL: %w", err)
+ }
+
+ return u.JoinPath("api", "v1", "user").String(), nil
+}
+
+func buildPostHogProbeURL(conn *coredata.Connector) (string, error) {
+ s, err := coredata.ConnectorSettings[coredata.PostHogConnectorSettings](conn)
+ if err != nil {
+ return "", fmt.Errorf("cannot read posthog connector settings: %w", err)
+ }
+
+ baseURL := strings.TrimSpace(s.BaseURL)
+ if baseURL == "" {
+ return "", nil
+ }
+
+ return url.JoinPath(baseURL, posthogOrganizationPath)
+}
+
+func probeLinear(
+ ctx context.Context,
+ httpClient *http.Client,
+ _ *coredata.Connector,
+) error {
+ return probePOSTJSON(
+ ctx,
+ httpClient,
+ linearGraphQLEndpoint,
+ map[string]string{"query": "{ viewer { id } }"},
+ nil,
+ )
+}
+
+func probeMonday(
+ ctx context.Context,
+ httpClient *http.Client,
+ _ *coredata.Connector,
+) error {
+ return probePOSTJSON(
+ ctx,
+ httpClient,
+ mondayGraphQLEndpoint,
+ map[string]string{"query": "query { users(limit: 1) { id } }"},
+ nil,
+ )
+}
+
+func probeAnthropic(
+ ctx context.Context,
+ httpClient *http.Client,
+ _ *coredata.Connector,
+) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, anthropicUsersProbeURL, nil)
+ if err != nil {
+ return fmt.Errorf("cannot create probe request: %w", err)
+ }
+
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("anthropic-version", anthropicAPIVersion)
+
+ return doProbeRequest(httpClient, req)
+}
+
+func probePostHog(
+ ctx context.Context,
+ httpClient *http.Client,
+ conn *coredata.Connector,
+) error {
+ probeURL, err := buildPostHogProbeURL(conn)
+ if err != nil {
+ return err
+ }
+
+ if probeURL != "" {
+ return probeGET(ctx, httpClient, probeURL)
+ }
+
+ for _, host := range []string{posthogUSBaseURL, posthogEUBaseURL} {
+ endpoint, err := url.JoinPath(host, posthogOrganizationPath)
+ if err != nil {
+ continue
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
+ if err != nil {
+ continue
+ }
+
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ if ctx.Err() != nil {
+ return fmt.Errorf("cannot probe posthog region: %w", ctx.Err())
+ }
+
+ continue
+ }
+
+ status := resp.StatusCode
+ _, _ = io.Copy(io.Discard, resp.Body)
+ _ = resp.Body.Close()
+
+ if status == http.StatusUnauthorized || status == http.StatusForbidden {
+ return fmt.Errorf("credential rejected: status %d", status)
+ }
+
+ if status >= http.StatusOK && status < http.StatusMultipleChoices {
+ return nil
+ }
+ }
+
+ return fmt.Errorf("credential rejected: no posthog region accepted the connection")
+}
diff --git a/pkg/connector/provider/probe_test.go b/pkg/connector/provider/probe_test.go
new file mode 100644
index 000000000..7b030c398
--- /dev/null
+++ b/pkg/connector/provider/probe_test.go
@@ -0,0 +1,92 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// Permission to use, copy, modify, and/or distribute this software for any
+// purpose with or without fee is hereby granted, provided that the above
+// copyright notice and this permission notice appear in all copies.
+//
+// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
+// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
+// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
+// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
+// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
+// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
+// PERFORMANCE OF THIS SOFTWARE.
+
+package provider
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+func TestBuiltinRegistry_ProbeCoverage(t *testing.T) {
+ t.Parallel()
+
+ r := NewBuiltinRegistry()
+
+ for _, reg := range r.All() {
+ hasProbe := reg.Probe != nil || reg.ProbeURL != "" || reg.BuildProbeURL != nil
+ assert.True(t, hasProbe, "provider %s has no connection probe configured", reg.Provider)
+ }
+}
+
+func TestBuildDatadogProbeURL(t *testing.T) {
+ t.Parallel()
+
+ conn := &coredata.Connector{Provider: coredata.ConnectorProviderDatadog}
+ require.NoError(t, conn.SetSettings(&coredata.DatadogConnectorSettings{
+ Domain: "us3.datadoghq.com",
+ Region: "US3",
+ }))
+
+ probeURL, err := buildDatadogProbeURL(conn)
+ require.NoError(t, err)
+ assert.Equal(
+ t,
+ "https://api.us3.datadoghq.com/api/v2/users?page%5Bnumber%5D=0&page%5Bsize%5D=1",
+ probeURL,
+ )
+}
+
+func TestBuildZendeskProbeURL(t *testing.T) {
+ t.Parallel()
+
+ conn := &coredata.Connector{Provider: coredata.ConnectorProviderZendesk}
+ require.NoError(t, conn.SetSettings(&coredata.ZendeskConnectorSettings{
+ Subdomain: "acme",
+ }))
+
+ probeURL, err := buildZendeskProbeURL(conn)
+ require.NoError(t, err)
+ assert.Contains(t, probeURL, "https://acme.zendesk.com/api/v2/users.json")
+}
+
+func TestBuildOktaProbeURL(t *testing.T) {
+ t.Parallel()
+
+ conn := &coredata.Connector{Provider: coredata.ConnectorProviderOkta}
+ require.NoError(t, conn.SetSettings(&coredata.OktaConnectorSettings{
+ Domain: "acme.okta.com",
+ }))
+
+ probeURL, err := buildOktaProbeURL(conn)
+ require.NoError(t, err)
+ assert.Equal(t, "https://acme.okta.com/api/v1/users?limit=1", probeURL)
+}
+
+func TestBuildPostHogProbeURL(t *testing.T) {
+ t.Parallel()
+
+ conn := &coredata.Connector{Provider: coredata.ConnectorProviderPostHog}
+ require.NoError(t, conn.SetSettings(&coredata.PostHogConnectorSettings{
+ BaseURL: "https://us.posthog.com",
+ }))
+
+ probeURL, err := buildPostHogProbeURL(conn)
+ require.NoError(t, err)
+ assert.Equal(t, "https://us.posthog.com/api/organizations/@current/", probeURL)
+}
diff --git a/pkg/connector/provider/qovery.go b/pkg/connector/provider/qovery.go
index f60df313a..89e0e9770 100644
--- a/pkg/connector/provider/qovery.go
+++ b/pkg/connector/provider/qovery.go
@@ -30,6 +30,7 @@ func qoveryRegistration() *Registration {
DisplayName: "Qovery",
SupportsAPIKey: true,
APIKeyAuthScheme: "Token",
+ BuildProbeURL: buildQoveryProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "organizationId", Label: "Organization ID", Required: true},
},
diff --git a/pkg/connector/provider/render.go b/pkg/connector/provider/render.go
index 46ee132d9..f7a2f2545 100644
--- a/pkg/connector/provider/render.go
+++ b/pkg/connector/provider/render.go
@@ -35,6 +35,7 @@ func renderRegistration() *Registration {
Provider: coredata.ConnectorProviderRender,
DisplayName: "Render",
SupportsAPIKey: true,
+ BuildProbeURL: buildRenderProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "workspaceId", Label: "Workspace ID", Required: true},
},
diff --git a/pkg/connector/provider/signoz.go b/pkg/connector/provider/signoz.go
index 7f8b31a49..5ae3a1226 100644
--- a/pkg/connector/provider/signoz.go
+++ b/pkg/connector/provider/signoz.go
@@ -32,6 +32,7 @@ func signozRegistration() *Registration {
DisplayName: "SigNoz",
SupportsAPIKey: true,
APIKeyHeader: "SIGNOZ-API-KEY",
+ BuildProbeURL: buildSigNozProbeURL,
ExtraSettings: []ExtraSetting{
{Key: "baseUrl", Label: "Base URL", Required: true},
},
diff --git a/pkg/connector/provider/tailscale.go b/pkg/connector/provider/tailscale.go
index d668037de..a42f3db7e 100644
--- a/pkg/connector/provider/tailscale.go
+++ b/pkg/connector/provider/tailscale.go
@@ -28,6 +28,7 @@ func tailscaleRegistration() *Registration {
Provider: coredata.ConnectorProviderTailscale,
DisplayName: "Tailscale",
SupportsAPIKey: true,
+ ProbeURL: "https://api.tailscale.com/api/v2/tailnet/-/users",
NewDriver: func(_ context.Context, c *http.Client, _ *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
return drivers.NewTailscaleDriver(c), nil
},
diff --git a/pkg/connector/provider/types.go b/pkg/connector/provider/types.go
index d47ce6216..6cc070eec 100644
--- a/pkg/connector/provider/types.go
+++ b/pkg/connector/provider/types.go
@@ -104,6 +104,16 @@ type Registration struct {
// APIKeyConnection.
APIKeyAuthScheme string
+ // BuildProbeURL derives a per-connector probe URL when the API host or
+ // path depends on connector settings (e.g. a customer subdomain or
+ // instance URL). Nil for providers with a static ProbeURL.
+ BuildProbeURL func(*coredata.Connector) (string, error)
+ // Probe runs a provider-specific connection check when a plain GET
+ // against ProbeURL/BuildProbeURL is insufficient (e.g. GraphQL POST,
+ // extra headers, or multi-host region probing). Takes precedence over
+ // ProbeURL and BuildProbeURL when set.
+ Probe func(context.Context, *http.Client, *coredata.Connector) error
+
// Factory closures — wired by Stages 2 and 3.
NewDriver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) (drivers.Driver, error)
NewNameResolver func(context.Context, *http.Client, *coredata.Connector, *log.Logger) drivers.NameResolver
diff --git a/pkg/connector/provider/zendesk.go b/pkg/connector/provider/zendesk.go
index 25636759d..c933c9802 100644
--- a/pkg/connector/provider/zendesk.go
+++ b/pkg/connector/provider/zendesk.go
@@ -33,10 +33,8 @@ func zendeskRegistration() *Registration {
// persisted on the connector settings for the driver's API host. Unlike
// Datadog, Zendesk does NOT echo a host back on the callback, so
// BuildTokenURLForSite reads the subdomain from the state rather than a
- // query param. AuthURL, TokenURL, and ProbeURL are therefore empty: the
- // closures build the per-customer hosts, and a static probe URL is
- // impossible for a per-subdomain host (an empty probe is skipped; a dead
- // token surfaces on the first ListAccounts). The global confidential
+ // query param. AuthURL and TokenURL are empty: the closures build the
+ // per-customer hosts. BuildProbeURL targets the stored subdomain. The global confidential
// client carries a client_secret, which both authenticates the token
// exchange (default post-form) and signs the state.
return &Registration{
@@ -45,6 +43,7 @@ func zendeskRegistration() *Registration {
OAuth2Scopes: []string{"users:read"},
BuildAuthURLForSite: connector.ZendeskAuthorizeURL,
BuildTokenURLForSite: connector.ZendeskTokenURL,
+ BuildProbeURL: buildZendeskProbeURL,
NewDriver: func(_ context.Context, c *http.Client, conn *coredata.Connector, _ *log.Logger) (drivers.Driver, error) {
s, err := coredata.ConnectorSettings[coredata.ZendeskConnectorSettings](conn)
if err != nil {
diff --git a/pkg/server/api/console/v1/access_review_campaign_resolvers.go b/pkg/server/api/console/v1/access_review_campaign_resolvers.go
index fd51f1d38..2278a3c44 100644
--- a/pkg/server/api/console/v1/access_review_campaign_resolvers.go
+++ b/pkg/server/api/console/v1/access_review_campaign_resolvers.go
@@ -508,15 +508,11 @@ func (r *accessReviewSourceResolver) ConnectionStatus(ctx context.Context, obj *
return types.AccessReviewSourceConnectionStatusDisconnected, nil
}
- if dbConnector.Protocol != coredata.ConnectorProtocolOAuth2 {
- return types.AccessReviewSourceConnectionStatusConnected, nil
- }
-
- // Creating an HTTP client may succeed even with an expired token
- // (e.g. no refresh token available). Make a lightweight probe
- // request to verify the token is actually valid.
- probeURL := r.providerRegistry.ProbeURL(string(dbConnector.Provider))
- if err := probeConnection(ctx, httpClient, probeURL); err != nil {
+ // Creating an HTTP client may succeed even with an expired or invalid
+ // credential (e.g. no refresh token available, or a dead API key).
+ // When the provider registers a probe, make a lightweight request to
+ // verify the credential is actually accepted.
+ if err := r.providerRegistry.ProbeConnection(ctx, httpClient, dbConnector); err != nil {
return types.AccessReviewSourceConnectionStatusDisconnected, nil
}
diff --git a/pkg/server/api/console/v1/provider_organizations.go b/pkg/server/api/console/v1/provider_organizations.go
deleted file mode 100644
index 7d30fbf5a..000000000
--- a/pkg/server/api/console/v1/provider_organizations.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (c) 2026 Probo Inc .
-//
-// Permission to use, copy, modify, and/or distribute this software for any
-// purpose with or without fee is hereby granted, provided that the above
-// copyright notice and this permission notice appear in all copies.
-//
-// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
-// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
-// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
-// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
-// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
-// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
-// PERFORMANCE OF THIS SOFTWARE.
-
-package console_v1
-
-import (
- "context"
- "fmt"
- "io"
- "net/http"
-)
-
-// probeConnection makes a lightweight API call to the given URL to verify
-// the OAuth token is still valid. The probe URL is configured per
-// connector in the connector registry.
-func probeConnection(ctx context.Context, httpClient *http.Client, probeURL string) error {
- if probeURL == "" {
- return nil
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, probeURL, nil)
- if err != nil {
- return fmt.Errorf("cannot create probe request: %w", err)
- }
-
- req.Header.Set("Accept", "application/json")
-
- resp, err := httpClient.Do(req)
- if err != nil {
- return fmt.Errorf("probe request failed: %w", err)
- }
-
- defer func() {
- _, _ = io.Copy(io.Discard, resp.Body)
- _ = resp.Body.Close()
- }()
-
- if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
- return fmt.Errorf("token rejected: status %d", resp.StatusCode)
- }
-
- return nil
-}