scripts/check-catalog-dcr.ts
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bun
/**
* Catalog rot detector for DCR remote-OAuth connectors.
*
* Reusable CLI tool: takes a catalog path (a `ServerDetail` file or a
* directory of them) as a REQUIRED argument, picks every entry whose
* `_meta["ai.nimblebrain/connector"].auth === "dcr"`, and runs the
* full DCR flow end-to-end against each vendor:
*
* 1. Reachability — `HEAD <remotes[0].url>`. Anything non-network-error
* counts; vendors return 200 / 401 / 405 depending on auth state.
* DNS failure / timeout / 5xx = the URL is dead.
* 2. RFC 9728 protected-resource metadata — `GET <connector-origin>/
* .well-known/oauth-protected-resource`. Optional but preferred;
* yields the authorization-server origin(s).
* 3. RFC 8414 authorization-server metadata — `GET <as-origin>/
* .well-known/oauth-authorization-server`. Must yield JSON with
* `registration_endpoint` (RFC 7591) for the catalog claim of
* `auth: "dcr"` to be truthful.
* 4. **DCR registration probe** — POST a synthetic client to the
* `registration_endpoint` with the redirect URI given as the second
* argument.
* Catches vendors that advertise DCR but reject our redirect-URI
* host (Intercom, Vercel pattern: "redirect URI ... not in the
* allowlist").
* 5. **Authorize probe** — GET the `authorization_endpoint` with the
* client_id from step 4 and the same redirect URI. Catches
* vendors that accept DCR registration but reject the redirect
* URI at the authorize step (Canva pattern: "Invalid redirect
* URI. It must be from an allowed host."). A successful authorize
* probe responds with a redirect (302/303) to the vendor login
* flow, or a 200 login page; a 4xx with a "redirect URI" error
* string is the trapdoor.
*
* Steps 4 and 5 are what catch "DCR theater" — vendors that ship the
* RFC 7591 endpoints but enforce a parallel host allowlist that the
* spec is supposed to make obsolete. Pre-extension this script only
* verified step 3, and shipped #195 with three connectors (Canva,
* Intercom, Vercel) that broke at install time. See #200 for the
* vendor outreach to re-add them.
*
* Each entry passes only if all 5 steps succeed. Per-entry pass/fail
* report; exit 1 if any DCR entry fails.
*
* Network-dependent by design — NOT part of `bun run verify` (which is
* offline). The production catalog now lives in the deploy repo, so the
* automated PR gate is re-homed there (deployments#33), invoking this
* tool against the mounted catalog via the platform image. Run it
* locally against any catalog path before shipping a curation change.
*
* Static-auth entries are skipped — they're operator-pre-registered
* and don't use DCR. Their failure mode (operator hasn't set up the
* OAuth app for the workspace) is workspace-state, not catalog-rot.
*/
import { readCatalogServers } from "../src/connectors/catalog/read.ts";
import {
getNimbleBrainConnectorMeta,
type ServerDetail,
} from "../src/connectors/catalog/server-detail.ts";
const REQUEST_TIMEOUT_MS = 10_000;
const USAGE =
"Usage: bun run scripts/check-catalog-dcr.ts <catalog-file-or-dir> <redirect-uri>\n" +
"Probe DCR connector entries in the given catalog for install-time rot.\n\n" +
" <redirect-uri> the https callback the deployment registers with vendors,\n" +
" e.g. https://nb.example.com/v1/mcp-auth/callback";
/**
* Parse the probe's redirect URI. It is a REQUIRED argument, not a default:
* vendors judge the registration and authorize steps against their own
* redirect-host allowlists, so the probe is only truthful when it presents
* the callback a real deployment registers (`mcpAuthCallbackUrl()` on that
* deployment). A made-up host would fail every vendor that allowlists.
*/
function parseRedirectUri(raw: string | undefined): string | null {
if (!raw) return null;
try {
const url = new URL(raw);
return url.protocol === "https:" ? url.toString() : null;
} catch {
return null;
}
}
interface CheckResult {
name: string;
url: string;
pass: boolean;
reachability: { ok: boolean; status?: number; error?: string };
registrationEndpoint?: string;
authorizationEndpoint?: string;
failureReason?: string;
}
/**
* Pick the entries this probe checks: ONLY `auth: "dcr"`, with at least one
* remote to reach. Every other auth kind is skipped — their failure modes
* aren't catalog DCR rot. Exported + pure so an offline
* unit test exercises the selection — and the script's imports against
* the platform source — without the network probe or process exit.
*/
export function selectDcrEntries(servers: ServerDetail[]): ServerDetail[] {
return servers.filter((s) => {
const meta = getNimbleBrainConnectorMeta(s);
return meta?.auth === "dcr" && !!s.remotes && s.remotes.length > 0;
});
}
async function main(): Promise<void> {
// Both arguments are REQUIRED. The catalog path is a file or a directory
// of `ServerDetail` files. The real curated catalog now lives in the
// deploy repo (the in-image `BUNDLED_STATIC_CATALOG_PATH` is only the
// minimal example), so defaulting to it would silently green-check two
// illustrative entries instead of the production set. Point this at
// the mounted/checked-out catalog you actually want to probe.
const catalogPath = process.argv[2];
const redirectUri = parseRedirectUri(process.argv[3]);
if (!catalogPath || !redirectUri) {
console.error(USAGE);
process.exit(2);
}
const dcrEntries = selectDcrEntries(readCatalogServers(catalogPath));
if (dcrEntries.length === 0) {
console.log("No DCR entries found in catalog. Nothing to check.");
return;
}
console.log(
`Probing ${dcrEntries.length} DCR catalog entries (reachability + DCR registration + authorize)…\n`,
);
const results = await Promise.all(dcrEntries.map((s) => checkEntry(s, redirectUri)));
// Tabular report. Sort fails first so the eye lands on them.
results.sort((a, b) => Number(a.pass) - Number(b.pass));
const pad = (s: string, n: number) => s.padEnd(n);
const colName = Math.max(20, ...results.map((r) => r.name.length));
const colUrl = Math.max(30, ...results.map((r) => r.url.length));
console.log(`${pad("ENTRY", colName)} ${pad("URL", colUrl)} STATUS`);
console.log("─".repeat(colName + colUrl + 18));
for (const r of results) {
const icon = r.pass ? "✓" : "✗";
const status = r.pass
? `${icon} ok (DCR + authorize end-to-end)`
: `${icon} FAIL — ${r.failureReason}`;
console.log(`${pad(r.name, colName)} ${pad(r.url, colUrl)} ${status}`);
}
const failed = results.filter((r) => !r.pass);
console.log("");
if (failed.length > 0) {
console.log(`✗ ${failed.length}/${results.length} DCR entries failed.\n`);
for (const r of failed) {
console.log(` ${r.name} (${r.url}):`);
console.log(` ${r.failureReason}`);
}
process.exit(1);
}
console.log(`✓ All ${results.length} DCR entries pass.`);
}
async function checkEntry(s: ServerDetail, redirectUri: string): Promise<CheckResult> {
const url = s.remotes![0]!.url;
const result: CheckResult = {
name: s.name,
url,
pass: false,
reachability: { ok: false },
};
// 1. Reachability.
result.reachability = await probeReachable(url);
if (!result.reachability.ok) {
result.failureReason = `unreachable (${result.reachability.error ?? `HTTP ${result.reachability.status}`})`;
return result;
}
// 2 + 3. OAuth metadata discovery — same chain as
// workspace-oauth-provider.discoverAuthorizationServerOrigins.
const connectorOrigin = new URL(url).origin;
const asOrigins = await discoverAuthorizationServerOrigins(connectorOrigin);
let asMetadata: { registration_endpoint: string; authorization_endpoint?: string } | null = null;
for (const asOrigin of asOrigins) {
const m = await fetchAsMetadata(`${asOrigin}/.well-known/oauth-authorization-server`);
if (m) {
asMetadata = m;
break;
}
}
if (!asMetadata) {
result.failureReason = "no AS metadata advertised registration_endpoint (RFC 7591)";
return result;
}
result.registrationEndpoint = asMetadata.registration_endpoint;
if (asMetadata.authorization_endpoint) {
result.authorizationEndpoint = asMetadata.authorization_endpoint;
}
// 4. DCR registration probe — catches vendors that reject the
// redirect URI at the registration step (Intercom, Vercel pattern).
const regResult = await probeDcrRegistration(asMetadata.registration_endpoint, redirectUri);
if (!regResult.ok) {
result.failureReason = `DCR /register rejected our redirect URI: ${regResult.message}`;
return result;
}
// 5. Authorize probe — catches vendors that accept DCR registration
// but reject the redirect URI at the authorize step (Canva pattern).
if (!asMetadata.authorization_endpoint) {
// Without an authorize endpoint we can't probe step 5. Mark pass
// since DCR + the rest succeeded; flag for follow-up.
result.pass = true;
return result;
}
const authResult = await probeAuthorize(
asMetadata.authorization_endpoint,
regResult.clientId,
redirectUri,
);
if (!authResult.ok) {
result.failureReason = `/authorize rejected our redirect URI: ${authResult.message}`;
return result;
}
result.pass = true;
return result;
}
async function probeReachable(
url: string,
): Promise<{ ok: boolean; status?: number; error?: string }> {
try {
const res = await fetch(url, {
method: "HEAD",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
redirect: "manual",
});
if (res.status >= 500) {
return { ok: false, status: res.status };
}
return { ok: true, status: res.status };
} catch {
try {
const res = await fetch(url, {
method: "GET",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
redirect: "manual",
});
if (res.status >= 500) return { ok: false, status: res.status };
return { ok: true, status: res.status };
} catch (err2) {
return { ok: false, error: err2 instanceof Error ? err2.message : String(err2) };
}
}
}
async function discoverAuthorizationServerOrigins(connectorOrigin: string): Promise<string[]> {
const origins = new Set<string>();
try {
const prMetadataUrl = `${connectorOrigin}/.well-known/oauth-protected-resource`;
const res = await fetch(prMetadataUrl, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (res.ok) {
const body = (await res.json()) as { authorization_servers?: unknown };
if (Array.isArray(body.authorization_servers)) {
for (const entry of body.authorization_servers) {
if (typeof entry !== "string") continue;
try {
origins.add(new URL(entry).origin);
} catch {
// malformed AS entry — ignore
}
}
}
}
} catch {
// RFC 9728 not advertised — connector origin fallback below covers it.
}
origins.add(connectorOrigin);
return [...origins];
}
async function fetchAsMetadata(
metaUrl: string,
): Promise<{ registration_endpoint: string; authorization_endpoint?: string } | null> {
try {
const res = await fetch(metaUrl, {
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) return null;
const body = (await res.json()) as {
registration_endpoint?: unknown;
authorization_endpoint?: unknown;
};
if (typeof body.registration_endpoint !== "string") return null;
return {
registration_endpoint: body.registration_endpoint,
authorization_endpoint:
typeof body.authorization_endpoint === "string" ? body.authorization_endpoint : undefined,
};
} catch {
return null;
}
}
/**
* POST a synthetic DCR client to the registration endpoint. Returns
* `{ ok: true, clientId }` on success or `{ ok: false, message }` on
* vendor-side rejection (typically host-allowlist failures).
*/
async function probeDcrRegistration(
registrationEndpoint: string,
redirectUri: string,
): Promise<{ ok: true; clientId: string } | { ok: false; message: string }> {
try {
const res = await fetch(registrationEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "NimbleBrain catalog probe",
redirect_uris: [redirectUri],
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none",
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
const text = await res.text();
if (!res.ok) {
// Try to surface the vendor's structured error_description if any.
let detail = text.slice(0, 200);
try {
const body = JSON.parse(text) as { error?: string; error_description?: string };
detail = body.error_description ?? body.error ?? detail;
} catch {
// not JSON; keep raw
}
return { ok: false, message: `HTTP ${res.status}: ${detail}` };
}
const body = JSON.parse(text) as { client_id?: unknown };
if (typeof body.client_id !== "string") {
return { ok: false, message: "registration response missing client_id" };
}
return { ok: true, clientId: body.client_id };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
/**
* GET the authorization endpoint with a synthetic state + PKCE pair.
* A spec-compliant vendor will redirect us to a login page (302/303)
* or render one inline (200). A vendor with a parallel host
* allowlist returns 4xx with a "redirect URI ... not allowed" body.
*/
async function probeAuthorize(
authorizationEndpoint: string,
clientId: string,
redirectUri: string,
): Promise<{ ok: true } | { ok: false; message: string }> {
// PKCE challenge — `code_challenge_method=S256` of an arbitrary
// verifier. Some vendors require it; supplying it never hurts.
const url = new URL(authorizationEndpoint);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", clientId);
url.searchParams.set("redirect_uri", redirectUri);
url.searchParams.set("scope", "");
url.searchParams.set("state", "probe");
url.searchParams.set("code_challenge", "Wph4LpxPDcXGKQQjkmFwIyMu5ZKLXEUW2Bn7sV3vqYU");
url.searchParams.set("code_challenge_method", "S256");
try {
const res = await fetch(url.toString(), {
method: "GET",
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
redirect: "manual",
});
// 2xx (login page) or 3xx (redirect to vendor login) = pass.
if (res.status < 400) return { ok: true };
const body = (await res.text()).slice(0, 200).replace(/\s+/g, " ").trim();
return { ok: false, message: `HTTP ${res.status}: ${body}` };
} catch (err) {
return {
ok: false,
message: err instanceof Error ? err.message : String(err),
};
}
}
// Only probe when run directly — importing the module (e.g. from the
// unit test) must not trigger the network run or the argv/exit handling.
if (import.meta.main) {
await main();
}