From 4d0c7f98e2f281f8279c11e07d14d79c49f3f57d Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 11:49:03 -0600 Subject: [PATCH 01/17] fix(reference): decide browser setup from the selected artifact, not a roster Whether the owner could add an account for a browser connector depended on an optional vendored conformance roster. When the roster did not load, no connector could be added. When it did, a connector on its scaffold list was refused even after it was published and installed. The new projection in server/connector-readiness.ts answers from three separate facts: the artifact a run would execute (a developer-local source, else the installed version, re-verified against its recorded bytes), whether this host meets that artifact's declared requirements (the scheduler's own checks), and the signed catalog, which only decides between "install" and "not published" when nothing is selected. The result is an action (connect, install, repair, configure, retry, unavailable) and a reason. Roster membership and the listing tier are not inputs. One resolver feeds the owner template list, a new readiness endpoint (GET /v1/owner/connector-install/readiness) and the browser-enrollment shell route. The route recomputes the verdict when it is called and returns 409 unless the action is connect, so a stale console cannot enroll a connector whose artifact has since broken. Removes browserEnrollmentSupport, isKnownScaffoldConnector and the is_known_scaffold template field. The roster exports stay in polyfill-connectors-runtime.ts for non-availability callers. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../runtime/scheduler-readiness.ts | 17 +- .../server/connection-setup-plan.ts | 62 +-- .../server/connector-install/index.ts | 55 +++ .../server/connector-readiness.ts | 237 ++++++++++++ reference-implementation/server/index.ts | 86 ++-- .../server/routes/owner-connector-install.ts | 41 ++ .../routes/owner-connector-templates.ts | 85 ++-- .../routes/ref-browser-enrollment-shell.ts | 25 +- .../browser-enrollment-shell-route.test.ts | 90 ++++- .../test/browser-enrollment-support.test.ts | 73 ---- .../test/connection-setup-plan.test.ts | 45 +-- .../test/connector-install.test.ts | 18 +- .../test/connector-readiness-routes.test.ts | 246 ++++++++++++ .../test/connector-readiness.test.ts | 366 ++++++++++++++++++ ...ector-registry-manifest-derivation.test.ts | 5 +- .../test/helpers/browser-artifact-fixtures.ts | 101 +++++ ...tor-templates-projection-isolation.test.ts | 1 + .../test/owner-connector-templates.test.ts | 37 +- 18 files changed, 1339 insertions(+), 251 deletions(-) create mode 100644 reference-implementation/server/connector-readiness.ts delete mode 100644 reference-implementation/test/browser-enrollment-support.test.ts create mode 100644 reference-implementation/test/connector-readiness-routes.test.ts create mode 100644 reference-implementation/test/connector-readiness.test.ts create mode 100644 reference-implementation/test/helpers/browser-artifact-fixtures.ts diff --git a/reference-implementation/runtime/scheduler-readiness.ts b/reference-implementation/runtime/scheduler-readiness.ts index 311b1eb93..d91b21742 100644 --- a/reference-implementation/runtime/scheduler-readiness.ts +++ b/reference-implementation/runtime/scheduler-readiness.ts @@ -205,8 +205,13 @@ async function checkFirstPartyLocalSourceReadiness(manifest: SchedulerManifest): return `required local source path(s) are missing or unreadable: ${missing.join(", ")}`; } -export async function defaultReadinessChecker(schedule: ConnectorSchedule): Promise { - const requirements = getRuntimeRequirements(schedule.manifest); +/** + * Whether this host can run a connector with these declared requirements. + * The scheduler asks before an automatic run; the owner-facing readiness + * projection asks with the same function, so there is one set of host rules. + */ +export async function checkManifestHostReadiness(manifest: SchedulerManifest): Promise { + const requirements = getRuntimeRequirements(manifest); for (const tool of requirements.external_tools || []) { // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. if (!(await runDetectCommand(tool))) { @@ -214,17 +219,21 @@ export async function defaultReadinessChecker(schedule: ConnectorSchedule): Prom } } - if (requiredBindingEnabled(schedule.manifest, "browser") && !browserSurfaceConfigured()) { + if (requiredBindingEnabled(manifest, "browser") && !browserSurfaceConfigured()) { return { ready: false, reason: "required browser runtime is not configured for unattended scheduled runs", }; } - const localSourceReason = await checkFirstPartyLocalSourceReadiness(schedule.manifest); + const localSourceReason = await checkFirstPartyLocalSourceReadiness(manifest); if (localSourceReason) { return { ready: false, reason: localSourceReason }; } return { ready: true }; } + +export function defaultReadinessChecker(schedule: ConnectorSchedule): Promise { + return checkManifestHostReadiness(schedule.manifest); +} diff --git a/reference-implementation/server/connection-setup-plan.ts b/reference-implementation/server/connection-setup-plan.ts index 88c0e2b00..bc3e964aa 100644 --- a/reference-implementation/server/connection-setup-plan.ts +++ b/reference-implementation/server/connection-setup-plan.ts @@ -7,8 +7,6 @@ import { normalizeStaticSecretCredentialCapture, type StaticSecretCredentialCaptureFieldLike, type StaticSecretFieldType, - CONFORMANCE_ROSTER_LOADED, - KNOWN_SCAFFOLD_CONNECTORS, PRODUCTION_READY_CONNECTORS, credentialValidationMode, } from "./polyfill-connectors-runtime.ts"; @@ -354,26 +352,6 @@ export const SUPPORTED_BROWSER_COLLECTOR_CONNECTORS: readonly SupportedBrowserCo ) ); -/** - * Whether this connector key names a KNOWN scaffold: manifest ships, - * registers, and validates, but its `collect()` is an unconditional - * `SKIP_RESULT` placeholder with no real parsing/pagination/cursor logic. - * `connector-conformance-roster.ts`'s `KNOWN_SCAFFOLD_CONNECTORS` is the - * single hand-maintained list this reads (kept disjoint from - * `PRODUCTION_READY_CONNECTORS` and `REAL_UNLISTED_CONNECTORS` by - * `connector-conformance.test.ts`), so a Development-tier connector never - * needs a second allowlist to tell "unproven but real" apart from - * "unimplemented": a genuine stub must never render an add action, even - * inside a Development disclosure, because clicking it can never collect - * anything. - */ -export function isKnownScaffoldConnector(connectorKey: string | null | undefined): boolean { - return ( - typeof connectorKey === "string" && - (KNOWN_SCAFFOLD_CONNECTORS as readonly string[]).includes(canonicalConnectorKey(connectorKey) ?? connectorKey) - ); -} - export const PROVIDER_AUTH_RUNBOOK_PATH = "docs/operator/add-connection.md"; // Connector keys for which the provider-authorization lifecycle (initiate + @@ -611,35 +589,6 @@ export function isBrowserBoundConnector(connectorId: string | null | undefined): ); } -export interface BrowserEnrollmentSupport { - /** The manifest declares a browser binding, so the browser-session flow applies. */ - readonly browserBound: boolean; - /** The browser-session flow may add a new account for this connector. */ - readonly canAddAccount: boolean; -} - -/** - * Browser-session support from the connector's own manifest bindings, shared - * by the console's browser-session routes and the RI's enrollment-shell route - * so both give the same answer. A connector installed from the runtime catalog - * qualifies through its bindings, without a connector-key list. - * - * A known scaffold can never collect, so it cannot add an account. The - * scaffold list comes from the optional conformance roster; when the roster - * did not load, no connector can add an account, because a scaffold cannot be - * told apart from a real connector. - */ -export function browserEnrollmentSupport( - connectorKey: string, - manifest: ConnectorManifestLike | null -): BrowserEnrollmentSupport { - const browserBound = classifyConnectorIntentModality(manifest) === "browser_bound"; - return { - browserBound, - canAddAccount: browserBound && CONFORMANCE_ROSTER_LOADED && !isKnownScaffoldConnector(connectorKey), - }; -} - export function classifyConnectorIntentModality(manifest: ConnectorManifestLike | null): ConnectorIntentModality { if (!manifest) { return "unknown"; @@ -895,6 +844,7 @@ export function unsupportedReason(modality: ConnectorIntentModality | ConnectorS } type ConnectionSetupPlanContext = Readonly<{ + browserEnrollmentRunnable: boolean; connectorKey: string; connectorModality: ConnectorIntentModality; deploymentReadiness: ConnectorSetupDeploymentReadiness; @@ -996,9 +946,10 @@ function buildLocalCollectorSetupPlan(ctx: ConnectionSetupPlanContext): Connecti } function buildBrowserBoundSetupPlan(ctx: ConnectionSetupPlanContext): ConnectionSetupPlan { - // The same rule as the console's browser-session routes and the RI's - // enrollment-shell route. Production readiness is the tier badge, not a gate. - const hasManualBrowserPath = browserEnrollmentSupport(ctx.connectorKey, ctx.manifest).canAddAccount; + // The caller passes the server-owned readiness projection's verdict + // (`connector-readiness.ts`). Tier, roster membership and the catalog never + // decide it; absent, the connector is not runnable. + const hasManualBrowserPath = ctx.browserEnrollmentRunnable; return { catalogDisposition: hasManualBrowserPath ? "browser_collector_manual" : "browser_bound_runbook", connectorKey: ctx.connectorKey, @@ -1198,6 +1149,8 @@ function buildUnsupportedSetupPlan(ctx: ConnectionSetupPlanContext): ConnectionS } export function buildConnectionSetupPlan(args: { + /** True when the readiness projection says the owner can connect now. */ + readonly browserEnrollmentRunnable?: boolean; readonly connectorKey?: string | null; readonly configuredProviderAuthConnectorKeys?: readonly string[]; readonly deploymentEnv?: DeploymentEnvLike; @@ -1235,6 +1188,7 @@ export function buildConnectionSetupPlan(args: { const validationMode: CredentialValidationMode = setupModality === "static_secret" ? credentialValidationMode(connectorKey) : "first_sync"; const ctx: ConnectionSetupPlanContext = { + browserEnrollmentRunnable: args.browserEnrollmentRunnable === true, connectorKey, connectorModality, deploymentReadiness, diff --git a/reference-implementation/server/connector-install/index.ts b/reference-implementation/server/connector-install/index.ts index dcf2c284a..764a71186 100644 --- a/reference-implementation/server/connector-install/index.ts +++ b/reference-implementation/server/connector-install/index.ts @@ -40,6 +40,7 @@ import { import { acquireDirectoryLock, type DirectoryLock } from "./directory-lock.ts"; import { createFileLocalConnectorSourceStore, + inspectActiveLocalConnectorSource, type LocalConnectorSourceRecord, type LocalConnectorSourceStore, } from "./local-source.ts"; @@ -555,6 +556,58 @@ export async function listVerifiedActiveConnectors(store: ConnectorInstallStore) return { invalid, verified }; } +/** + * The artifact a run would execute for this connector, chosen the way the run + * path chooses it: a selected developer-local source first (an invalid one + * blocks, there is no fallback), then the installed active version. Both are + * re-verified against their recorded bytes. A store that throws is not handled + * here: the caller decides what "could not inspect" means. + */ +export type SelectedConnectorArtifact = + | { readonly status: "none" } + | { + readonly status: "active"; + readonly source: "installed" | "developer_local"; + readonly version: string | null; + readonly digest: string | null; + readonly manifest: Record; + } + | { readonly status: "invalid"; readonly source: "installed" | "developer_local"; readonly reason: string }; + +export async function inspectSelectedConnectorArtifact( + store: ConnectorInstallStore, + localStore: LocalConnectorSourceStore, + connectorId: string +): Promise { + const local = await inspectActiveLocalConnectorSource(localStore, connectorId); + if (local.status === "invalid") { + return { reason: local.reason, source: "developer_local", status: "invalid" }; + } + if (local.status === "active") { + return { + digest: local.record.entrypointSha256, + manifest: local.record.manifest, + source: "developer_local", + status: "active", + version: local.record.version, + }; + } + const installed = await inspectActiveConnector(store, connectorId); + if (installed.status === "invalid") { + return { reason: installed.reason, source: "installed", status: "invalid" }; + } + if (installed.status === "active") { + return { + digest: installed.record.digest, + manifest: installed.record.manifest, + source: "installed", + status: "active", + version: installed.record.version, + }; + } + return { status: "none" }; +} + /** Returns a runnable installed entrypoint only after every recorded byte check passes. */ export async function resolveActiveConnectorPath( store: ConnectorInstallStore, @@ -763,6 +816,7 @@ export interface ConnectorInstallService { catalog: () => Promise; install: (connectorId: string, digest: string) => Promise; resolveManifestFromCatalog?: (connectorId: string) => Promise | null>; + inspectSelected: (connectorId: string) => Promise; addLocalSource?: (sourcePath: string) => Promise; listLocalSources?: () => Promise; reloadLocalSource?: (sourceId: string) => Promise; @@ -1155,6 +1209,7 @@ export function createConnectorInstallService(options: { reloadLocalSource: (sourceId) => localSourceStore.reload(sourceId), removeLocalSource: (sourceId) => localSourceStore.remove(sourceId), selectLocalSource: (connectorKey, sourceId) => localSourceStore.select(connectorKey, sourceId), + inspectSelected: (connectorId) => inspectSelectedConnectorArtifact(store, localSourceStore, connectorId), status: async () => (await listVerifiedActiveConnectors(store)).verified, uninstall, async update(connectorId) { diff --git a/reference-implementation/server/connector-readiness.ts b/reference-implementation/server/connector-readiness.ts new file mode 100644 index 000000000..6ed59c4f3 --- /dev/null +++ b/reference-implementation/server/connector-readiness.ts @@ -0,0 +1,237 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Whether the owner can add an account through browser setup for a connector. + * + * One projection, three inputs, kept apart: + * - the selected artifact: what a run would execute (a developer-local + * source, else the installed version), re-verified against its recorded + * bytes; + * - host readiness: the artifact's declared requirements against this host, + * by the same rules the scheduler applies; + * - the signed catalog: discovery only. It decides between "install" and + * "not published" when no artifact is selected. It never makes anything + * runnable. + * Roster membership and the listing tier are not inputs. The console's + * picker and the enrollment route call this same resolver, so they cannot + * disagree. + */ + +import { checkManifestHostReadiness } from "../runtime/scheduler-readiness.ts"; +import { classifyConnectorIntentModality, type ConnectorManifestLike } from "./connection-setup-plan.ts"; +import { canonicalConnectorKey } from "./connector-key.ts"; +import type { ConnectorCatalogEntry, ConnectorInstallService, SelectedConnectorArtifact } from "./connector-install/index.ts"; + +export type ReadinessAction = "connect" | "install" | "repair" | "configure" | "retry" | "unavailable"; + +export type ReadinessReason = + | "runnable" + | "not_installed" + | "not_published" + | "catalog_unavailable" + | "artifact_invalid" + | "inspection_unavailable" + | "not_browser_bound" + | "host_requirement_unmet"; + +export type CatalogFact = "offered" | "not_offered" | "unavailable" | "not_consulted"; + +export interface BrowserEnrollmentReadiness { + readonly action: ReadinessAction; + readonly artifact: { + readonly digest: string | null; + readonly source: "installed" | "developer_local" | null; + readonly state: "none" | "verified" | "invalid" | "inspection_failed"; + readonly version: string | null; + }; + readonly browser_bound: boolean; + readonly catalog: CatalogFact; + readonly connector_key: string; + /** Owner-readable cause for repair, configure and retry. Null otherwise. */ + readonly detail: string | null; + readonly host: "ready" | "blocked" | "not_evaluated"; + readonly reason: ReadinessReason; +} + +export type ArtifactFact = SelectedConnectorArtifact | { readonly status: "inspection_failed"; readonly reason: string }; + +export interface ReadinessFacts { + readonly artifact: ArtifactFact; + readonly catalog: CatalogFact; + /** From the offered catalog entry's bindings. Used only when no artifact is selected. */ + readonly catalogBrowserBound: boolean | null; + readonly connectorKey: string; + /** Null when the host was not asked: no verified browser-bound artifact. */ + readonly host: { readonly ready: boolean; readonly reason?: string } | null; + /** A registered manifest the caller holds. The last resort for `browser_bound`. */ + readonly hintBrowserBound: boolean; +} + +function isBrowserBound(manifest: Record | null): boolean { + return classifyConnectorIntentModality(manifest as ConnectorManifestLike | null) === "browser_bound"; +} + +function artifactSummary(artifact: ArtifactFact): BrowserEnrollmentReadiness["artifact"] { + switch (artifact.status) { + case "active": + return { digest: artifact.digest, source: artifact.source, state: "verified", version: artifact.version }; + case "invalid": + return { digest: null, source: artifact.source, state: "invalid", version: null }; + case "inspection_failed": + return { digest: null, source: null, state: "inspection_failed", version: null }; + default: + return { digest: null, source: null, state: "none", version: null }; + } +} + +/** + * Pure: every state is a function of the facts. Rules are evaluated in order: + * + * artifact inspection threw -> retry inspection_unavailable + * artifact bytes fail verification -> repair artifact_invalid + * artifact verified, manifest not browser-bound -> unavailable not_browser_bound + * artifact verified, host requirement unmet -> configure host_requirement_unmet + * artifact verified, host ready -> connect runnable + * no artifact, catalog offers it -> install not_installed + * no artifact, catalog unreachable -> retry catalog_unavailable + * no artifact, catalog does not offer it -> unavailable not_published + * + * A selected artifact is runnable whatever the catalog says: an older + * installed version and an unpublished developer-local source both connect. + */ +export function projectBrowserEnrollmentReadiness(facts: ReadinessFacts): BrowserEnrollmentReadiness { + const { artifact } = facts; + const base = { + artifact: artifactSummary(artifact), + catalog: facts.catalog, + connector_key: facts.connectorKey, + detail: null, + host: "not_evaluated" as const, + }; + const browserBound = + artifact.status === "active" + ? isBrowserBound(artifact.manifest) + : (facts.catalogBrowserBound ?? facts.hintBrowserBound); + const result = ( + action: ReadinessAction, + reason: ReadinessReason, + extra: Partial> = {} + ): BrowserEnrollmentReadiness => ({ ...base, ...extra, action, browser_bound: browserBound, reason }); + + if (artifact.status === "inspection_failed") { + return result("retry", "inspection_unavailable", { detail: artifact.reason }); + } + if (artifact.status === "invalid") { + return result("repair", "artifact_invalid", { detail: artifact.reason }); + } + if (artifact.status === "active") { + if (!browserBound) { + return result("unavailable", "not_browser_bound"); + } + if (facts.host && !facts.host.ready) { + return result("configure", "host_requirement_unmet", { detail: facts.host.reason ?? null, host: "blocked" }); + } + return result("connect", "runnable", { host: "ready" }); + } + if (facts.catalog === "offered") { + return browserBound ? result("install", "not_installed") : result("unavailable", "not_browser_bound"); + } + if (facts.catalog === "unavailable") { + return result("retry", "catalog_unavailable"); + } + return result("unavailable", facts.catalog === "not_offered" ? "not_published" : "not_installed"); +} + +export interface BrowserEnrollmentReadinessResolver { + resolve: (connectorKey: string) => Promise; + /** One catalog read for the whole list. */ + resolveMany: (connectorKeys: readonly string[]) => Promise; +} + +type CatalogRead = () => Promise; + +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function catalogFactFor(connectorKey: string, entries: readonly ConnectorCatalogEntry[] | null) { + if (entries === null) { + return { browserBound: null, fact: "unavailable" as const }; + } + const matches = entries.filter( + (candidate) => + (canonicalConnectorKey(candidate.connector_key) ?? candidate.connector_key) === connectorKey || + candidate.connector_id === connectorKey + ); + const offered = matches.find((candidate) => candidate.latest === true) ?? matches[0]; + if (!offered) { + return { browserBound: null, fact: "not_offered" as const }; + } + return { + browserBound: offered.bindings + ? isBrowserBound({ runtime_requirements: { bindings: offered.bindings } }) + : null, + fact: "offered" as const, + }; +} + +export function createBrowserEnrollmentReadinessResolver(deps: { + readonly installService: Pick; + /** + * The registered manifest, if any. It is the last resort for `browser_bound` + * when nothing is selected and the catalog is silent, so every caller gets the + * same answer for the same connector. It never makes anything runnable. + */ + readonly registeredManifest?: (connectorKey: string) => Promise | null>; + /** Test seam; production uses the scheduler's host rules. */ + readonly checkHost?: typeof checkManifestHostReadiness; +}): BrowserEnrollmentReadinessResolver { + const checkHost = deps.checkHost ?? checkManifestHostReadiness; + + const resolveWith = async ( + rawKey: string, + readCatalog: CatalogRead + ): Promise => { + const connectorKey = canonicalConnectorKey(rawKey) ?? rawKey; + let artifact: ArtifactFact; + try { + artifact = await deps.installService.inspectSelected(connectorKey); + } catch (error) { + artifact = { reason: messageOf(error), status: "inspection_failed" }; + } + let host: ReadinessFacts["host"] = null; + if (artifact.status === "active" && isBrowserBound(artifact.manifest)) { + host = await checkHost(artifact.manifest); + } + const hint = await deps.registeredManifest?.(connectorKey).catch(() => null); + const catalog = + artifact.status === "none" + ? catalogFactFor(connectorKey, await readCatalog()) + : { browserBound: null, fact: "not_consulted" as const }; + return projectBrowserEnrollmentReadiness({ + artifact, + catalog: catalog.fact, + catalogBrowserBound: catalog.browserBound, + connectorKey, + hintBrowserBound: isBrowserBound(hint ?? null), + host, + }); + }; + + const catalogReader = (): CatalogRead => { + let pending: Promise | undefined; + return () => { + pending ??= deps.installService.catalog().catch(() => null); + return pending; + }; + }; + + return { + resolve: (connectorKey) => resolveWith(connectorKey, catalogReader()), + resolveMany: (connectorKeys) => { + const readCatalog = catalogReader(); + return Promise.all(connectorKeys.map((connectorKey) => resolveWith(connectorKey, readCatalog))); + }, + }; +} diff --git a/reference-implementation/server/index.ts b/reference-implementation/server/index.ts index 13cb81d52..2ae472293 100644 --- a/reference-implementation/server/index.ts +++ b/reference-implementation/server/index.ts @@ -95,6 +95,7 @@ import { withConnectorLifecycleLock, } from "./connector-lifecycle-lock.ts"; import { createFileLocalConnectorSourceStore } from "./connector-install/local-source.ts"; +import { createBrowserEnrollmentReadinessResolver } from "./connector-readiness.ts"; import { createRemoteAccessConfigStore, remoteAccessConfigPath } from "./remote-access-store.ts"; import { ownerPasswordManagedByDesktop, ownerPasswordOwnerSet } from "./owner-password-owner-set.ts"; import { appConfigPath, createAppConfigStore } from "./app-config-store.ts"; @@ -6913,6 +6914,11 @@ export function buildAsApp(opts: ServerOpts = {}) { getOwnerSubjectId, handleError, pdppError, + readinessResolver: createBrowserEnrollmentReadinessResolver({ + installService: connectorInstallService, + registeredManifest: async (connectorKey) => + resolveRegisteredConnectorManifest(connectorKey) as Promise | null>, + }), requireOwnerSession: ownerAuth.requireOwnerSession, resolveRegisteredConnectorManifest, setReferenceTraceId, @@ -8433,6 +8439,48 @@ function buildRsApp(opts: ServerOpts = {}) { setReferenceTraceId, } as unknown as Parameters[1]); + const ownerConnectorInstallService = + opts.connectorInstallService ?? + createConnectorInstallService({ + ...installedConnectorManifestRegistration, + removeActivation: removeConnectorActivation, + assertUninstallAllowed: async (connectorId) => { + const activeRun = isPostgresStorageBackend() + ? (await postgresQuery<{ run_id: string }>( + "SELECT run_id FROM controller_active_runs WHERE connector_id=$1 ORDER BY started_at LIMIT 1", + [connectorId] + )).rows[0] + : getDb() + .prepare("SELECT run_id FROM controller_active_runs WHERE connector_id=? ORDER BY started_at LIMIT 1") + .get<{ run_id: string }>(connectorId); + if (activeRun) { + throw Object.assign(new Error(`Cannot uninstall ${connectorId} while run ${activeRun.run_id} is active.`), { + code: "connector_run_active", + }); + } + const ids = isPostgresStorageBackend() + ? (await postgresQuery<{ connector_instance_id: string }>( + "SELECT connector_instance_id FROM connector_instances WHERE connector_id=$1 ORDER BY connector_instance_id", + [connectorId] + )).rows.map((row) => row.connector_instance_id) + : getDb().prepare( + "SELECT connector_instance_id FROM connector_instances WHERE connector_id=? ORDER BY connector_instance_id" + ).all<{ connector_instance_id: string }>(connectorId).map((row) => row.connector_instance_id); + if (ids.length) { + const error = new Error(`Cannot uninstall ${connectorId}: ${ids.length} source${ids.length === 1 ? "" : "s"} still use this connector.`) as Error & { code: string; connection_ids: string[] }; + error.code = "connector_in_use"; + error.connection_ids = ids; + throw error; + } + }, + }); + + const ownerConnectorReadinessResolver = createBrowserEnrollmentReadinessResolver({ + installService: ownerConnectorInstallService, + registeredManifest: async (connectorKey) => + (await getConnectorManifest(connectorKey)) as Record | null, + }); + // GET /v1/owner/connector-templates is the bearer-authed owner-agent template // catalog. It separates connector implementation metadata from configured // connection instances, embeds related connection summaries, and reports @@ -8448,6 +8496,7 @@ function buildRsApp(opts: ServerOpts = {}) { handleError, listRegisteredConnectorIds, projectStorageDisplayName, + readinessResolver: ownerConnectorReadinessResolver, requireOwner, requireToken, resolveResource: (req: unknown) => @@ -8465,43 +8514,10 @@ function buildRsApp(opts: ServerOpts = {}) { // `connector_implementation`; drop the cached list so the next read shows it. onImplementationChanged: invalidateConnectorSummariesCache, pdppError, + readinessResolver: ownerConnectorReadinessResolver, requireOwner, requireToken, - service: - opts.connectorInstallService ?? - createConnectorInstallService({ - ...installedConnectorManifestRegistration, - removeActivation: removeConnectorActivation, - assertUninstallAllowed: async (connectorId) => { - const activeRun = isPostgresStorageBackend() - ? (await postgresQuery<{ run_id: string }>( - "SELECT run_id FROM controller_active_runs WHERE connector_id=$1 ORDER BY started_at LIMIT 1", - [connectorId] - )).rows[0] - : getDb() - .prepare("SELECT run_id FROM controller_active_runs WHERE connector_id=? ORDER BY started_at LIMIT 1") - .get<{ run_id: string }>(connectorId); - if (activeRun) { - throw Object.assign(new Error(`Cannot uninstall ${connectorId} while run ${activeRun.run_id} is active.`), { - code: "connector_run_active", - }); - } - const ids = isPostgresStorageBackend() - ? (await postgresQuery<{ connector_instance_id: string }>( - "SELECT connector_instance_id FROM connector_instances WHERE connector_id=$1 ORDER BY connector_instance_id", - [connectorId] - )).rows.map((row) => row.connector_instance_id) - : getDb().prepare( - "SELECT connector_instance_id FROM connector_instances WHERE connector_id=? ORDER BY connector_instance_id" - ).all<{ connector_instance_id: string }>(connectorId).map((row) => row.connector_instance_id); - if (ids.length) { - const error = new Error(`Cannot uninstall ${connectorId}: ${ids.length} source${ids.length === 1 ? "" : "s"} still use this connector.`) as Error & { code: string; connection_ids: string[] }; - error.code = "connector_in_use"; - error.connection_ids = ids; - throw error; - } - }, - }), + service: ownerConnectorInstallService, } as unknown as Parameters[1]); // Owner-authenticated HTTP routes for both remote-access providers diff --git a/reference-implementation/server/routes/owner-connector-install.ts b/reference-implementation/server/routes/owner-connector-install.ts index 8a3475788..47a646234 100644 --- a/reference-implementation/server/routes/owner-connector-install.ts +++ b/reference-implementation/server/routes/owner-connector-install.ts @@ -7,10 +7,12 @@ import type { ConnectorInstallService, } from "../connector-install/index.ts"; import type { LocalConnectorSourceRecord } from "../connector-install/local-source.ts"; +import type { BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; import type { MiddlewareHandler, PdppErrorFn, RouteArg } from "./_route-contract.ts"; interface Request { readonly body?: Record; + readonly query?: Record; } interface Response { json: (body: unknown) => unknown; @@ -73,6 +75,28 @@ function projectLocalSource(record: LocalConnectorSourceRecord): Record (typeof item === "string" && item.trim() ? [item.trim()] : [])); +} + +/** Every connector an owner could be offered browser setup for: published, installed or developer-local. */ +async function readinessCandidateKeys(service: ConnectorInstallService): Promise { + const [catalog, installed, local] = await Promise.all([ + service.catalog().catch(() => []), + service.status().catch(() => []), + (service.listLocalSources?.() ?? Promise.resolve([])).catch(() => []), + ]); + return [ + ...new Set([ + ...catalog.map((entry) => entry.connector_id), + ...installed.map((record) => record.connectorId), + ...local.map((record) => record.connectorKey), + ]), + ].sort(); +} + function bodyString(req: Request, key: string): string | null { const value = req.body?.[key]; return typeof value === "string" && value.trim() ? value.trim() : null; @@ -92,6 +116,8 @@ export function mountOwnerConnectorInstall( */ readonly onImplementationChanged?: () => void; readonly pdppError: PdppErrorFn; + /** The server-owned browser-setup verdict, shared with the template list and the enrollment route. */ + readonly readinessResolver: BrowserEnrollmentReadinessResolver; } ): void { const guarded = [options.requireToken, options.requireOwner] as const; @@ -244,7 +270,22 @@ export function mountOwnerConnectorInstall( options.handleError(res, error); } }; + // One projection per connector: `connector_id` (repeatable) names them; with + // none named, every published, installed or developer-local connector. + const readinessHandler: Handler = async (req, res) => { + try { + const requested = queryList(req, "connector_id"); + const keys = requested.length > 0 ? requested : await readinessCandidateKeys(options.service); + res.json({ + data: await options.readinessResolver.resolveMany(keys), + object: "connector_browser_enrollment_readiness", + }); + } catch (error) { + options.handleError(res, error); + } + }; app.get("/v1/owner/connector-install/catalog", ...guarded, catalogHandler); + app.get("/v1/owner/connector-install/readiness", ...guarded, readinessHandler); app.get("/v1/owner/connector-install/status", ...guarded, statusHandler); app.get("/v1/owner/connector-install/local-sources", ...guarded, localSourcesHandler); app.post("/v1/owner/connector-install/install", ...guarded, installHandler); diff --git a/reference-implementation/server/routes/owner-connector-templates.ts b/reference-implementation/server/routes/owner-connector-templates.ts index f39e49e32..9b018c3eb 100644 --- a/reference-implementation/server/routes/owner-connector-templates.ts +++ b/reference-implementation/server/routes/owner-connector-templates.ts @@ -13,11 +13,11 @@ // supported. Interactive browser setup remains owner-mediated in Console. import { - browserEnrollmentSupport, buildConnectionSetupPlan, - isKnownScaffoldConnector, + classifyConnectorIntentModality, staticSecretCredentialCaptureFromManifest, } from "../connection-setup-plan.ts"; +import type { BrowserEnrollmentReadiness, BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; import type { OwnerAgentControlAction } from "../metadata.ts"; import type { MiddlewareHandler, RouteArg } from "./_route-contract.ts"; @@ -93,6 +93,8 @@ export interface MountOwnerConnectorTemplatesContext { displayName: string | null | undefined, options: { connectorId?: string | null; connectorInstanceId?: string | null } ) => string | null; + /** The one server-owned answer to "can the owner connect this browser connector now". */ + readinessResolver: BrowserEnrollmentReadinessResolver; requireOwner: MiddlewareHandler; requireToken: MiddlewareHandler; resolveResource: (req: unknown) => string; @@ -192,13 +194,13 @@ export function isSupportedOwnerActionPlan(plan: ReturnType, - manifest: ConnectorManifestLike + browserRunnable: boolean ): boolean { if (plan.connectorModality !== "browser_bound") { return false; @@ -207,27 +209,23 @@ export function isOwnerSessionBrowserActionPlan( return plan.nextStepKind === "enroll_browser_collector" && typeof plan.enrollmentKey === "string"; } return ( - plan.catalogDisposition === "static_secret_connect" && - plan.setupModality === "static_secret" && - browserEnrollmentSupport(plan.connectorKey, manifest).canAddAccount + plan.catalogDisposition === "static_secret_connect" && plan.setupModality === "static_secret" && browserRunnable ); } -function isOwnerActionablePlan( - plan: ReturnType, - manifest: ConnectorManifestLike -): boolean { - return isSupportedOwnerActionPlan(plan) || isOwnerSessionBrowserActionPlan(plan, manifest); +function isOwnerActionablePlan(plan: ReturnType, browserRunnable: boolean): boolean { + return isSupportedOwnerActionPlan(plan) || isOwnerSessionBrowserActionPlan(plan, browserRunnable); } function isUatExposablePlan( plan: ReturnType, manifest: ConnectorManifestLike, + browserRunnable: boolean, connectorKey?: string | null, allowlist?: ReadonlySet ): boolean { const basePlan = [ - isOwnerActionablePlan(plan, manifest), + isOwnerActionablePlan(plan, browserRunnable), plan.catalogDisposition === "static_secret_experimental", plan.catalogDisposition === "static_secret_connect" && plan.setupModality === "static_secret" && @@ -256,6 +254,7 @@ function isUatExposablePlan( } function buildTemplateSupportedActions(args: { + browserRunnable: boolean; manifest: ConnectorManifestLike; plan: ReturnType; resource: string; @@ -276,7 +275,7 @@ function buildTemplateSupportedActions(args: { }, ]; } - if (isActionable && isOwnerSessionBrowserActionPlan(args.plan, args.manifest)) { + if (isActionable && isOwnerSessionBrowserActionPlan(args.plan, args.browserRunnable)) { return [ { family: "initiate_connection", @@ -300,13 +299,14 @@ function buildTemplateSupportedActions(args: { function projectSetupPlan( manifest: ConnectorManifestLike, - plan: ReturnType + plan: ReturnType, + browserRunnable: boolean ): Record { // Owner-facing actionability: manifest is listed AND plan is actionable (lifecycle authority unchanged). // UAT exposure via uat_expose_unlisted_connectors does not modify setup_plan; it is a separate flag // for the console to enable testing without claiming Supported or Preview tier. const isActionableManifest = isActionablePublicListing(manifest); - const isActionablePlan = isOwnerActionablePlan(plan, manifest); + const isActionablePlan = isOwnerActionablePlan(plan, browserRunnable); const ownerActionable = isActionableManifest && isActionablePlan; return { catalog_disposition: plan.catalogDisposition, @@ -329,13 +329,16 @@ function projectTemplate( ctx: MountOwnerConnectorTemplatesContext, manifest: ConnectorManifestLike, connectionsByConnector: ReadonlyMap, - resource: string + resource: string, + readiness: BrowserEnrollmentReadiness | null ): Record | null { const connectorKey = connectorKeyFromManifest(ctx, manifest); if (!connectorKey) { return null; } + const browserRunnable = readiness?.action === "connect"; const plan = buildConnectionSetupPlan({ + browserEnrollmentRunnable: browserRunnable, configuredProviderAuthConnectorKeys: ctx.configuredProviderAuthConnectorKeys ?? [], connectorKey, manifest, @@ -354,8 +357,10 @@ function projectTemplate( const uatExposeUnlistedConnectors = ctx.uatExposeUnlistedConnectors === true && - ((isUnproven && isUatExposablePlan(plan, manifest, connectorKey, ctx.uatConnectorAllowlist)) || - (isDevelopment && isAllowlisted && isUatExposablePlan(plan, manifest, connectorKey, ctx.uatConnectorAllowlist))); + ((isUnproven && isUatExposablePlan(plan, manifest, browserRunnable, connectorKey, ctx.uatConnectorAllowlist)) || + (isDevelopment && + isAllowlisted && + isUatExposablePlan(plan, manifest, browserRunnable, connectorKey, ctx.uatConnectorAllowlist))); return { connection_count: connections.length, @@ -363,20 +368,23 @@ function projectTemplate( connector_id: connectorKey, connector_key: connectorKey, connector_modality: modality, + // The server-owned browser-setup verdict (connector-readiness.ts). Null for + // a template that is not browser-bound. + connector_readiness: readiness, display_name: displayNameForTemplate(connectorKey, manifest), icon: manifest.icon ?? null, - // Development-tier-only fact: whether this connector is a KNOWN scaffold - // (unconditional SKIP_RESULT, no real collection) versus real-but-unproven. - // The console needs this to decide whether a Development-tier card may - // ever render an add action -- a scaffold must never get one, because - // clicking it cannot collect anything. - is_known_scaffold: isKnownScaffoldConnector(connectorKey), object: "owner_connector_template", public_listing: manifest.capabilities?.public_listing ?? null, registration_status: "registered", - setup_plan: projectSetupPlan(manifest, plan), + setup_plan: projectSetupPlan(manifest, plan, browserRunnable), stream_count: Array.isArray(manifest.streams) ? manifest.streams.length : 0, - supported_actions: buildTemplateSupportedActions({ manifest, plan, resource, uatExposeUnlistedConnectors }), + supported_actions: buildTemplateSupportedActions({ + browserRunnable, + manifest, + plan, + resource, + uatExposeUnlistedConnectors, + }), uat_expose_unlisted_connectors: uatExposeUnlistedConnectors, version: manifest.version ?? null, }; @@ -403,6 +411,21 @@ async function collectConnectorTemplates(ctx: MountOwnerConnectorTemplatesContex .map(([, manifest]) => manifest); } +/** Browser-bound templates only: other templates have no browser-setup verdict. */ +async function readinessByConnectorKey( + ctx: MountOwnerConnectorTemplatesContext, + templates: readonly ConnectorManifestLike[] +): Promise> { + const browserBound = templates.flatMap((manifest) => { + const connectorKey = connectorKeyFromManifest(ctx, manifest); + return connectorKey && classifyConnectorIntentModality(manifest) === "browser_bound" + ? [connectorKey] + : []; + }); + const resolved = await ctx.readinessResolver.resolveMany(browserBound); + return new Map(resolved.map((entry) => [entry.connector_key, entry])); +} + async function connectionsByConnectorKey( ctx: MountOwnerConnectorTemplatesContext, ownerSubjectId: string @@ -438,6 +461,7 @@ export function mountOwnerConnectorTemplates(app: AppLike, ctx: MountOwnerConnec collectConnectorTemplates(ctx), connectionsByConnectorKey(ctx, ownerSubjectId), ]); + const readiness = await readinessByConnectorKey(ctx, templates); res.json({ data: templates .map((manifest) => { @@ -445,7 +469,8 @@ export function mountOwnerConnectorTemplates(app: AppLike, ctx: MountOwnerConnec // template from an owner agent — same isolation as the read loop // in collectConnectorTemplates above. try { - return projectTemplate(ctx, manifest, connections, resource); + const key = connectorKeyFromManifest(ctx, manifest); + return projectTemplate(ctx, manifest, connections, resource, (key && readiness.get(key)) || null); } catch { return null; } diff --git a/reference-implementation/server/routes/ref-browser-enrollment-shell.ts b/reference-implementation/server/routes/ref-browser-enrollment-shell.ts index ebe7704ed..8237d4d62 100644 --- a/reference-implementation/server/routes/ref-browser-enrollment-shell.ts +++ b/reference-implementation/server/routes/ref-browser-enrollment-shell.ts @@ -28,20 +28,18 @@ // - No provider secret, browser session cookie, or credential accepted/returned. // - Only browser-bound connectors accepted; static-secret/local-collector // connectors are refused with a typed 409. -// - Browser-bound comes from the registered manifest's bindings -// (`browserEnrollmentSupport`, shared with the console). A known scaffold, -// or any connector when the conformance roster did not load, is refused -// with a typed 409. +// - Whether a shell may be created is the server-owned readiness +// projection (`connector-readiness.ts`), computed at request time: the +// selected artifact must verify and the host must meet its requirements. +// The console's picker reads the same projection. A connector that is not +// runnable is refused with a typed 409 that names the reason. // - Shells are invisible to every list/count/owner-console surface until enrollment // captures source identity and flips the shell to `active`. import { randomBytes } from "node:crypto"; -import { - browserEnrollmentSupport, - type ConnectorManifestLike, - displayNameForConnector, -} from "../connection-setup-plan.ts"; +import { type ConnectorManifestLike, displayNameForConnector } from "../connection-setup-plan.ts"; +import type { BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; import type { CredentialStateChange } from "../stores/connector-instance-credential-store.ts"; import type { MiddlewareHandler, PdppErrorFn, RouteArg } from "./_route-contract.ts"; @@ -153,6 +151,7 @@ export interface MountRefBrowserEnrollmentShellContext { handleError: (res: unknown, err: unknown) => void; now?: () => string; pdppError: PdppErrorFn; + readinessResolver: BrowserEnrollmentReadinessResolver; requireOwnerSession: MiddlewareHandler; resolveRegisteredConnectorManifest: (connectorId: string) => Promise; setReferenceTraceId: (res: RouteResponse, traceId: string) => void; @@ -276,8 +275,8 @@ export function mountRefBrowserEnrollmentShell(app: AppLike, ctx: MountRefBrowse // Reject unknown connector before doing anything else (404). const manifest = await ctx.resolveRegisteredConnectorManifest(connectorId); - const support = browserEnrollmentSupport(connectorId, manifest); - if (!support.browserBound) { + const readiness = await ctx.readinessResolver.resolve(connectorId); + if (!readiness.browser_bound) { await emitShellAudit(ctx, req, res, { connectorId, error: { code: "connector_not_browser_bound" }, @@ -293,7 +292,7 @@ export function mountRefBrowserEnrollmentShell(app: AppLike, ctx: MountRefBrowse ); return; } - if (!support.canAddAccount) { + if (readiness.action !== "connect") { await emitShellAudit(ctx, req, res, { connectorId, error: { code: "connector_browser_setup_unavailable" }, @@ -305,7 +304,7 @@ export function mountRefBrowserEnrollmentShell(app: AppLike, ctx: MountRefBrowse res, 409, "connector_browser_setup_unavailable", - `Connector '${connectorId}' cannot add an account through browser setup: it is a known scaffold, or this server cannot load the connector conformance roster.` + `Connector '${connectorId}' cannot add an account through browser setup now: ${readiness.reason}${readiness.detail ? ` (${readiness.detail})` : ""}. Next action: ${readiness.action}.` ); return; } diff --git a/reference-implementation/test/browser-enrollment-shell-route.test.ts b/reference-implementation/test/browser-enrollment-shell-route.test.ts index e6e9ce8c4..76eb1c2c5 100644 --- a/reference-implementation/test/browser-enrollment-shell-route.test.ts +++ b/reference-implementation/test/browser-enrollment-shell-route.test.ts @@ -12,7 +12,10 @@ import { retireExpiredBrowserEnrollmentShells, } from "../server/browser-enrollment-shell-retirement.ts"; import { getDb } from "../server/db.ts"; +import { createConnectorInstallService } from "../server/connector-install/index.ts"; +import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; import { startServer } from "../server/index.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; import { BROWSER_ENROLLMENT_SHELL_TTL_MS } from "../server/routes/ref-browser-enrollment-shell.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; @@ -35,9 +38,38 @@ function loadManifest(name: string): unknown { return entry.manifest; } -async function registerConnector(asUrl: string, name: string): Promise { +// Registration alone is not code availability. Each server below runs with a +// browser-capable host and a verified developer-local artifact for every +// connector `registerConnector` publishes, unless a test opts out. +let fixtures: ReturnType | null = null; +let restoreHost: (() => void) | null = null; + +// The readiness projection reads the signed catalog when nothing is installed; +// a test must not reach the network for it. +function emptyCatalogInstallService() { + return createConnectorInstallService({ + ...installedConnectorManifestRegistration, + catalogLoader: async () => [], + }); +} + +function openFixtures(): void { + fixtures = createBrowserArtifactFixtures(); + restoreHost = setBrowserHostCapability(true); +} + +function closeFixtures(): void { + restoreHost?.(); + fixtures?.close(); + fixtures = null; + restoreHost = null; +} + +async function registerConnector(asUrl: string, name: string, options: { publish?: boolean } = {}): Promise { + const manifest = loadManifest(name) as Record; + const registered = options.publish === false ? manifest : await fixtures?.publish(manifest); const resp = await fetch(`${asUrl}/connectors`, { - body: JSON.stringify(loadManifest(name)), + body: JSON.stringify(registered), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -91,9 +123,11 @@ async function closeServer(server: TestServer): Promise { } async function withServer(fn: (urls: { asUrl: string; rsUrl: string }) => Promise): Promise { + openFixtures(); const server = await startServer({ asPort: 0, autoEnrollEligibleSchedules: false, + connectorInstallService: emptyCatalogInstallService(), dbPath: ":memory:", ownerAuthPassword: OWNER_PASSWORD, ownerAuthSubjectId: OWNER_SUBJECT_ID, @@ -106,6 +140,7 @@ async function withServer(fn: (urls: { asUrl: string; rsUrl: string }) => Promis await fn({ asUrl, rsUrl }); } finally { await closeServer(server); + closeFixtures(); } } @@ -117,9 +152,11 @@ async function withServer(fn: (urls: { asUrl: string; rsUrl: string }) => Promis // cookie — same pattern as static-secret-draft-connection-route.test.ts's // `withOpenServer`. async function withOpenServer(fn: (urls: { asUrl: string; rsUrl: string }) => Promise): Promise { + openFixtures(); const server = await startServer({ asPort: 0, autoEnrollEligibleSchedules: false, + connectorInstallService: emptyCatalogInstallService(), dbPath: ":memory:", ownerAuthPassword: "", ownerAuthSubjectId: OWNER_SUBJECT_ID, @@ -132,6 +169,7 @@ async function withOpenServer(fn: (urls: { asUrl: string; rsUrl: string }) => Pr await fn({ asUrl, rsUrl }); } finally { await closeServer(server); + closeFixtures(); } } @@ -316,7 +354,7 @@ test("browser-enrollment shell: creates draft for a browser-bound connector outs display_name: "Acme Shop", }; const registered = await fetch(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify(await fixtures?.publish(manifest)), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -331,17 +369,55 @@ test("browser-enrollment shell: creates draft for a browser-bound connector outs }); }); -test("browser-enrollment shell: rejects a known scaffold (409)", async () => { +test("browser-enrollment shell: refuses a registered connector that has no runnable artifact (409)", async () => { await withServer(async ({ asUrl }) => { - await registerConnector(asUrl, "anthropic"); + // Registered, but nothing installed or developer-local selected: the + // manifest is a description, not code the host can run. + await registerConnector(asUrl, "anthropic", { publish: false }); const cookie = await ownerLogin(asUrl); const res = await fetch(`${asUrl}/_ref/connectors/anthropic/browser-enrollment-shell`, { headers: { cookie }, method: "POST", }); assert.equal(res.status, 409); - const body = await jsonBody(res); - assert.equal(asRecord(body.error).code, "connector_browser_setup_unavailable"); + const error = asRecord((await jsonBody(res)).error); + assert.equal(error.code, "connector_browser_setup_unavailable"); + assert.match(asString(error.message), /not_published/); + assert.match(asString(error.message), /Next action: unavailable/); + }); +}); + +test("browser-enrollment shell: refuses when the selected artifact no longer verifies (409)", async () => { + await withServer(async ({ asUrl }) => { + await registerConnector(asUrl, "chase"); + fixtures?.corrupt("chase"); + const cookie = await ownerLogin(asUrl); + const res = await fetch(`${asUrl}/_ref/connectors/chase/browser-enrollment-shell`, { + headers: { cookie }, + method: "POST", + }); + assert.equal(res.status, 409); + const error = asRecord((await jsonBody(res)).error); + assert.equal(error.code, "connector_browser_setup_unavailable"); + assert.match(asString(error.message), /artifact_invalid/); + assert.match(asString(error.message), /Next action: repair/); + }); +}); + +test("browser-enrollment shell: refuses when this host has no browser surface (409)", async () => { + await withServer(async ({ asUrl }) => { + await registerConnector(asUrl, "chase"); + setBrowserHostCapability(false); + const cookie = await ownerLogin(asUrl); + const res = await fetch(`${asUrl}/_ref/connectors/chase/browser-enrollment-shell`, { + headers: { cookie }, + method: "POST", + }); + assert.equal(res.status, 409); + const error = asRecord((await jsonBody(res)).error); + assert.equal(error.code, "connector_browser_setup_unavailable"); + assert.match(asString(error.message), /host_requirement_unmet/); + assert.match(asString(error.message), /Next action: configure/); }); }); diff --git a/reference-implementation/test/browser-enrollment-support.test.ts b/reference-implementation/test/browser-enrollment-support.test.ts deleted file mode 100644 index 59ccc3f78..000000000 --- a/reference-implementation/test/browser-enrollment-support.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -/** - * `browserEnrollmentSupport` is the one rule the console's browser-session - * routes and the RI's enrollment-shell route apply. It reads the manifest's - * bindings, excludes known scaffolds, and fails closed when the optional - * conformance roster did not load. - */ - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import test from "node:test"; -import { fileURLToPath, pathToFileURL } from "node:url"; - -import { browserEnrollmentSupport } from "../server/connection-setup-plan.ts"; - -const CONNECTION_SETUP_PLAN = fileURLToPath(new URL("../server/connection-setup-plan.ts", import.meta.url)); - -const browser = { runtime_requirements: { bindings: { browser: { required: true }, network: { required: true } } } }; -const network = { runtime_requirements: { bindings: { network: { required: true } } } }; - -test("a browser binding permits a new account, including a connector no key list names", () => { - assert.deepEqual(browserEnrollmentSupport("reddit", browser), { browserBound: true, canAddAccount: true }); - assert.deepEqual(browserEnrollmentSupport("acme-shop", browser), { browserBound: true, canAddAccount: true }); -}); - -test("a known scaffold stays browser-bound but cannot add an account", () => { - assert.deepEqual(browserEnrollmentSupport("anthropic", browser), { browserBound: true, canAddAccount: false }); -}); - -test("a connector without a browser binding is not browser-bound", () => { - assert.deepEqual(browserEnrollmentSupport("github", network), { browserBound: false, canAddAccount: false }); - assert.deepEqual(browserEnrollmentSupport("reddit", null), { browserBound: false, canAddAccount: false }); -}); - -test("without the conformance roster no connector can add an account", () => { - const hook = ` -import { registerHooks } from "node:module"; -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier.startsWith("@pdpp/polyfill-connectors/")) { - throw Object.assign(new Error("Cannot find module '" + specifier + "'"), { code: "MODULE_NOT_FOUND" }); - } - return nextResolve(specifier, context); - }, -}); -`; - const probe = ` -const { browserEnrollmentSupport } = await import(${JSON.stringify(pathToFileURL(CONNECTION_SETUP_PLAN).href)}); -const browser = ${JSON.stringify(browser)}; -process.stdout.write(JSON.stringify(["reddit", "acme-shop", "anthropic"].map((key) => browserEnrollmentSupport(key, browser)))); -`; - const child = spawnSync( - process.execPath, - [ - "--import", - "tsx", - "--import", - `data:text/javascript,${encodeURIComponent(hook)}`, - "--input-type=module", - "--eval", - probe, - ], - { cwd: fileURLToPath(new URL("..", import.meta.url)), encoding: "utf8" } - ); - assert.equal(child.status, 0, child.stderr); - assert.deepEqual(JSON.parse(child.stdout), [ - { browserBound: true, canAddAccount: false }, - { browserBound: true, canAddAccount: false }, - { browserBound: true, canAddAccount: false }, - ]); -}); diff --git a/reference-implementation/test/connection-setup-plan.test.ts b/reference-implementation/test/connection-setup-plan.test.ts index 98cc9aa89..529d602cd 100644 --- a/reference-implementation/test/connection-setup-plan.test.ts +++ b/reference-implementation/test/connection-setup-plan.test.ts @@ -7,7 +7,6 @@ import { readPolyfillManifests } from "@pdpp/polyfill-connectors/manifests"; import type { ConnectorManifestLike } from "../server/connection-setup-plan.ts"; import { - browserEnrollmentSupport, buildConnectionSetupPlan, classifyConnectorIntentModality, classifyConnectorSetupModality, @@ -151,6 +150,7 @@ test("setup planner supports manual/upload connectors that declare an import env test("setup planner keeps browser-bound connectors proof-gated before live proof", () => { const amazon = buildConnectionSetupPlan({ + browserEnrollmentRunnable: true, connectorKey: "amazon", manifest: manifest("amazon", { browser: { required: true }, network: { required: true } }), }); @@ -165,6 +165,7 @@ test("setup planner keeps browser-bound connectors proof-gated before live proof assert.equal(amazon.runbookPath, null); const chase = buildConnectionSetupPlan({ + browserEnrollmentRunnable: true, connectorKey: "chase", manifest: manifest("chase", { browser: { required: true } }), }); @@ -172,32 +173,26 @@ test("setup planner keeps browser-bound connectors proof-gated before live proof assert.equal(chase.enrollmentKey, "chase"); }); -test("setup planner offers browser setup by browserEnrollmentSupport, not by production readiness", () => { - // acme-shop is a catalog-installed connector that no bundled key list names. - const acme = buildConnectionSetupPlan({ - connectorKey: "acme-shop", - manifest: manifest("acme-shop", { browser: { required: true } }), - }); - assert.equal(acme.catalogDisposition, "browser_collector_manual"); - assert.equal(acme.nextStepKind, "enroll_browser_collector"); - assert.equal(acme.enrollmentKey, "acme-shop"); - - const scaffold = buildConnectionSetupPlan({ - connectorKey: "anthropic", - manifest: manifest("anthropic", { browser: { required: true } }), - }); - assert.equal(scaffold.catalogDisposition, "browser_bound_runbook"); - assert.equal(scaffold.nextStepKind, "manual_runbook"); - assert.equal(scaffold.enrollmentKey, undefined); - +test("setup planner offers browser setup only on the readiness verdict, never by connector key", () => { + // The verdict is the server-owned readiness projection's `connect`. The + // planner reads nothing else: not the key, the tier, or a roster. for (const key of ["acme-shop", "anthropic", "amazon", "doordash", "reddit"]) { const keyManifest = manifest(key, { browser: { required: true } }); - const plan = buildConnectionSetupPlan({ connectorKey: key, manifest: keyManifest }); - assert.equal( - plan.catalogDisposition === "browser_collector_manual", - browserEnrollmentSupport(key, keyManifest).canAddAccount, - key - ); + const runnable = buildConnectionSetupPlan({ + browserEnrollmentRunnable: true, + connectorKey: key, + manifest: keyManifest, + }); + assert.equal(runnable.catalogDisposition, "browser_collector_manual", key); + assert.equal(runnable.nextStepKind, "enroll_browser_collector", key); + assert.equal(runnable.enrollmentKey, key, key); + + for (const notRunnable of [{ browserEnrollmentRunnable: false }, {}]) { + const plan = buildConnectionSetupPlan({ connectorKey: key, manifest: keyManifest, ...notRunnable }); + assert.equal(plan.catalogDisposition, "browser_bound_runbook", key); + assert.equal(plan.nextStepKind, "manual_runbook", key); + assert.equal(plan.enrollmentKey, undefined, key); + } } }); diff --git a/reference-implementation/test/connector-install.test.ts b/reference-implementation/test/connector-install.test.ts index 97e44e2a6..4be91d435 100644 --- a/reference-implementation/test/connector-install.test.ts +++ b/reference-implementation/test/connector-install.test.ts @@ -76,6 +76,12 @@ const installerCorePath = require.resolve("@opendatalabs/data-connectors-tools/i const { assertCatalog } = await import( pathToFileURL(join(dirname(installerCorePath), "catalog-schema.mjs")).href ); +// The install-route tests below never read readiness. +const unusedReadinessResolver = { + resolve: () => assert.fail("unexpected readiness read"), + resolveMany: () => assert.fail("unexpected readiness read"), +}; + const entry: ConnectorCatalogEntry = { bindings: { browser: "optional" }, config_digest: configDigest, @@ -1800,6 +1806,7 @@ test("owner connector-install routes register both guards and reject missing ins const tokenGuard = () => undefined; const ownerGuard = () => undefined; mountOwnerConnectorInstall(app as never, { + readinessResolver: unusedReadinessResolver, handleError: () => assert.fail("unexpected error"), pdppError: (_res, statusCode, code, message, param) => { status = statusCode; @@ -1808,6 +1815,7 @@ test("owner connector-install routes register both guards and reject missing ins requireOwner: ownerGuard, requireToken: tokenGuard, service: { + inspectSelected: async () => ({ status: "none" }), catalog: async () => [], install: async () => assert.fail("should not install"), uninstall: async () => assert.fail("should not uninstall"), @@ -1815,7 +1823,7 @@ test("owner connector-install routes register both guards and reject missing ins update: async () => assert.fail("should not update"), }, }); - assert.equal(registrations.size, 10, "the complete owner connector-install route set is registered"); + assert.equal(registrations.size, 11, "the complete owner connector-install route set is registered"); for (const [route, handlers] of registrations) { assert.equal(handlers.length, 3, `${route} registers two guards and one handler`); assert.equal(handlers[0], tokenGuard, `${route} requires an owner token`); @@ -1857,11 +1865,13 @@ test("owner uninstall route calls the service and returns the completed action", }, }; mountOwnerConnectorInstall(app as never, { + readinessResolver: unusedReadinessResolver, handleError: (_res, error) => assert.fail(String(error)), pdppError: (_res, status, code) => assert.fail(`${status} ${code}`), requireOwner: () => undefined, requireToken: () => undefined, service: { + inspectSelected: async () => ({ status: "none" }), catalog: async () => [], install: async () => assert.fail("unexpected install"), uninstall: async (connectorId) => { @@ -1907,6 +1917,7 @@ test("owner update route reports an implementation change only after the update }, }; mountOwnerConnectorInstall(app as never, { + readinessResolver: unusedReadinessResolver, handleError: () => undefined, onImplementationChanged: () => { changes += 1; @@ -1915,6 +1926,7 @@ test("owner update route reports an implementation change only after the update requireOwner: () => undefined, requireToken: () => undefined, service: { + inspectSelected: async () => ({ status: "none" }), catalog: async () => [], install: async () => assert.fail("unexpected install"), status: async () => [], @@ -1958,6 +1970,7 @@ test("owner uninstall route returns the source count refusal", async () => { }, }; mountOwnerConnectorInstall(app as never, { + readinessResolver: unusedReadinessResolver, handleError: (_res, error) => { seenError = error; }, @@ -1965,6 +1978,7 @@ test("owner uninstall route returns the source count refusal", async () => { requireOwner: () => undefined, requireToken: () => undefined, service: { + inspectSelected: async () => ({ status: "none" }), catalog: async () => [], install: async () => assert.fail("unexpected install"), uninstall: async () => { @@ -2006,11 +2020,13 @@ test("owner catalog route returns the verified catalog projection", async () => }, }; mountOwnerConnectorInstall(app as never, { + readinessResolver: unusedReadinessResolver, handleError: () => assert.fail("unexpected error"), pdppError: () => assert.fail("unexpected error"), requireOwner: () => undefined, requireToken: () => undefined, service: { + inspectSelected: async () => ({ status: "none" }), catalog: async () => [ { ...entry, diff --git a/reference-implementation/test/connector-readiness-routes.test.ts b/reference-implementation/test/connector-readiness-routes.test.ts new file mode 100644 index 000000000..2ebfee97b --- /dev/null +++ b/reference-implementation/test/connector-readiness-routes.test.ts @@ -0,0 +1,246 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * One projection, three surfaces. For every state, the owner template list + * (what the console's picker reads), the readiness endpoint, and the + * enrollment route (what refuses or creates a shell) must agree: a shell is + * created exactly when the projection says `connect`, and the template and the + * endpoint carry the same projection. The signed catalog is stubbed; no + * network. + */ + +import assert from "node:assert/strict"; +import test from "node:test"; +import { readPolyfillManifests } from "@pdpp/polyfill-connectors/manifests"; + +import { createConnectorInstallService } from "../server/connector-install/index.ts"; +import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; +import { startServer } from "../server/index.ts"; +import { closeTestServer } from "./helpers/close-test-server.ts"; +import { TEST_PRE_REGISTERED_PUBLIC_CLIENTS } from "./fixtures/demo-clients.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; + +const CATALOG_DIGEST = `sha256:${"a".repeat(64)}`; + +type TestServer = Awaited>; + +function asRecord(value: unknown): Record { + assert.ok(value !== null && typeof value === "object" && !Array.isArray(value), "expected a JSON object"); + return value as Record; +} + +async function fetchJson(url: string, init: RequestInit = {}): Promise<{ body: unknown; status: number }> { + const response = await fetch(url, init); + const text = await response.text(); + return { body: text ? JSON.parse(text) : null, status: response.status }; +} + +async function issueOwnerToken(asUrl: string): Promise { + const form = { "Content-Type": "application/x-www-form-urlencoded" }; + const device = asRecord( + ( + await fetchJson(`${asUrl}/oauth/device_authorization`, { + body: new URLSearchParams({ client_id: "cli_longview" }).toString(), + headers: form, + method: "POST", + }) + ).body + ); + await fetch(`${asUrl}/device/approve`, { + body: new URLSearchParams({ subject_id: "owner_local", user_code: String(device.user_code) }).toString(), + headers: form, + method: "POST", + }); + const token = asRecord( + ( + await fetchJson(`${asUrl}/oauth/token`, { + body: new URLSearchParams({ + client_id: "cli_longview", + device_code: String(device.device_code), + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }).toString(), + headers: form, + method: "POST", + }) + ).body + ); + return String(token.access_token); +} + +// A real browser-bound manifest under a made-up key: nothing about the key matters. +function browserManifest(connectorId: string): Record { + const entry = readPolyfillManifests().find((candidate) => candidate.file === "amazon.json"); + assert.ok(entry, "the amazon polyfill manifest is the browser-bound base"); + return { + ...(entry.manifest as Record), + capabilities: { public_listing: { tier: "development" } }, + connector_id: connectorId, + connector_key: connectorId, + display_name: connectorId, + setup: undefined, + }; +} + +async function register(asUrl: string, manifest: Record): Promise { + const response = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(manifest), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(response.status, 201, await response.text()); +} + +async function withStates( + options: { browserHost: boolean }, + run: (ctx: { + asUrl: string; + rsUrl: string; + token: string; + fixtures: ReturnType; + }) => Promise +): Promise { + const fixtures = createBrowserArtifactFixtures(); + const restoreHost = setBrowserHostCapability(options.browserHost); + const server = await startServer({ + asPort: 0, + autoEnrollEligibleSchedules: false, + connectorInstallService: createConnectorInstallService({ + ...installedConnectorManifestRegistration, + // One published connector that is not installed. + catalogLoader: async () => [ + { + bindings: { browser: { required: true } }, + config_digest: `sha256:${"c".repeat(64)}`, + connector_id: "acme-offered", + connector_key: "acme-offered", + digest: CATALOG_DIGEST, + latest: true, + tier: "development", + version: "1.0.0", + }, + ], + }), + dbPath: ":memory:", + ownerAuthPassword: "", + preRegisteredPublicClients: TEST_PRE_REGISTERED_PUBLIC_CLIENTS, + quiet: true, + rsPort: 0, + }); + const asUrl = `http://localhost:${server.asPort}`; + const rsUrl = `http://localhost:${server.rsPort}`; + try { + await run({ asUrl, fixtures, rsUrl, token: await issueOwnerToken(asUrl) }); + } finally { + await closeTestServer(server); + restoreHost(); + fixtures.close(); + } +} + +async function readinessEndpoint(rsUrl: string, token: string, key: string): Promise> { + const { body, status } = await fetchJson( + `${rsUrl}/v1/owner/connector-install/readiness?connector_id=${encodeURIComponent(key)}`, + { headers: { Authorization: `Bearer ${token}` } } + ); + assert.equal(status, 200); + const data = asRecord(body).data; + assert.ok(Array.isArray(data) && data.length === 1, "one projection per named connector"); + return asRecord(data[0]); +} + +async function templateReadiness(rsUrl: string, token: string, key: string): Promise { + const { body } = await fetchJson(`${rsUrl}/v1/owner/connector-templates`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const rows = asRecord(body).data; + assert.ok(Array.isArray(rows)); + const row = rows.map(asRecord).find((candidate) => candidate.connector_key === key); + assert.ok(row, `${key} must be a registered template`); + return row.connector_readiness; +} + +async function createShell(asUrl: string, key: string): Promise<{ status: number; body: Record }> { + const { body, status } = await fetchJson(`${asUrl}/_ref/connectors/${key}/browser-enrollment-shell`, { + method: "POST", + }); + return { body: asRecord(body), status }; +} + +test("the template list, the readiness endpoint and the enrollment route agree on every state", async () => { + await withStates({ browserHost: true }, async ({ asUrl, fixtures, rsUrl, token }) => { + // connect: a verified developer-local artifact. + await register(asUrl, (await fixtures.publish(browserManifest("acme-connect"))) as Record); + // repair: the same, then changed on disk. + await register(asUrl, (await fixtures.publish(browserManifest("acme-broken"))) as Record); + fixtures.corrupt("acme-broken"); + // unavailable: registered, nothing installed, not published. + await register(asUrl, browserManifest("acme-registered-only")); + + const expected = { + "acme-broken": "repair", + "acme-connect": "connect", + "acme-registered-only": "unavailable", + } as const; + for (const [key, action] of Object.entries(expected)) { + const fromEndpoint = await readinessEndpoint(rsUrl, token, key); + assert.equal(fromEndpoint.action, action, key); + assert.deepEqual(await templateReadiness(rsUrl, token, key), fromEndpoint, `${key}: template equals endpoint`); + + const shell = await createShell(asUrl, key); + if (action === "connect") { + assert.equal(shell.status, 201, `${key}: ${JSON.stringify(shell.body)}`); + } else { + assert.equal(shell.status, 409, key); + assert.equal(asRecord(shell.body.error).code, "connector_browser_setup_unavailable", key); + assert.match(String(asRecord(shell.body.error).message), new RegExp(String(fromEndpoint.reason)), key); + } + } + }); +}); + +test("a published connector that is not installed is offered install and cannot be enrolled", async () => { + await withStates({ browserHost: true }, async ({ asUrl, rsUrl, token }) => { + const readiness = await readinessEndpoint(rsUrl, token, "acme-offered"); + assert.equal(readiness.action, "install"); + assert.equal(readiness.reason, "not_installed"); + // Not registered either: nothing for the route to enroll. + assert.equal((await createShell(asUrl, "acme-offered")).status, 404); + }); +}); + +test("with no named connector the endpoint lists every published, installed or developer-local one", async () => { + await withStates({ browserHost: true }, async ({ asUrl, fixtures, rsUrl, token }) => { + await register(asUrl, (await fixtures.publish(browserManifest("acme-connect"))) as Record); + const { body } = await fetchJson(`${rsUrl}/v1/owner/connector-install/readiness`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const keys = (asRecord(body).data as unknown[]).map((row) => asRecord(row).connector_key); + assert.deepEqual(keys, ["acme-connect", "acme-offered"]); + }); +}); + +test("a host with no browser surface: every enrollment is refused with configure", async () => { + await withStates({ browserHost: false }, async ({ asUrl, fixtures, rsUrl, token }) => { + await register(asUrl, (await fixtures.publish(browserManifest("acme-connect"))) as Record); + const readiness = await readinessEndpoint(rsUrl, token, "acme-connect"); + assert.equal(readiness.action, "configure"); + assert.deepEqual(await templateReadiness(rsUrl, token, "acme-connect"), readiness); + const shell = await createShell(asUrl, "acme-connect"); + assert.equal(shell.status, 409); + assert.match(String(asRecord(shell.body.error).message), /host_requirement_unmet/); + }); +}); + +test("enforcement rechecks at action time: an artifact that breaks after the list was read is refused", async () => { + await withStates({ browserHost: true }, async ({ asUrl, fixtures, rsUrl, token }) => { + await register(asUrl, (await fixtures.publish(browserManifest("acme-connect"))) as Record); + assert.equal((await readinessEndpoint(rsUrl, token, "acme-connect")).action, "connect"); + + fixtures.corrupt("acme-connect"); + + const shell = await createShell(asUrl, "acme-connect"); + assert.equal(shell.status, 409); + assert.match(String(asRecord(shell.body.error).message), /artifact_invalid/); + }); +}); diff --git a/reference-implementation/test/connector-readiness.test.ts b/reference-implementation/test/connector-readiness.test.ts new file mode 100644 index 000000000..7f69def85 --- /dev/null +++ b/reference-implementation/test/connector-readiness.test.ts @@ -0,0 +1,366 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * The browser-setup readiness projection, over real install and local-source + * stores. Each test states one row of the state table in + * `projectBrowserEnrollmentReadiness` in `server/connector-readiness.ts`: the + * signed catalog, the selected artifact, and the host decide the action and the reason. Roster membership + * and the listing tier are not inputs, so none appear here. + */ + +import assert from "node:assert/strict"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + type ConnectorCatalogEntry, + createConnectorInstallService, + createFileConnectorInstallStore, + type ConnectorInstallService, +} from "../server/connector-install/index.ts"; +import { createFileLocalConnectorSourceStore } from "../server/connector-install/local-source.ts"; +import { + type BrowserEnrollmentReadiness, + createBrowserEnrollmentReadinessResolver, + projectBrowserEnrollmentReadiness, +} from "../server/connector-readiness.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; + +const CONFIG_DIGEST = `sha256:${"c".repeat(64)}`; +const DIGEST_V1 = `sha256:${"a".repeat(64)}`; +const DIGEST_V2 = `sha256:${"b".repeat(64)}`; + +const BROWSER_BINDINGS = { browser: { required: true }, network: { required: true } }; +const NETWORK_BINDINGS = { network: { required: true } }; + +function catalogEntry( + connectorId: string, + digest: string, + version: string, + bindings: Record = BROWSER_BINDINGS +): ConnectorCatalogEntry { + return { + bindings, + config_digest: CONFIG_DIGEST, + connector_id: connectorId, + connector_key: connectorId, + digest, + latest: true, + tier: "development", + version, + }; +} + +function writeArtifact(root: string, connectorId: string, version: string, bindings: Record): void { + mkdirSync(join(root, "profile"), { recursive: true }); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "profile", "collection-profile.json"), + JSON.stringify({ connector_id: connectorId, runtime_requirements: { bindings }, version }) + ); + writeFileSync(join(root, "dist", "collection-profile.mjs"), "export {};\n"); + writeFileSync(join(root, "provenance.json"), "{}\n"); +} + +interface Harness { + readonly dataDir: string; + readonly install: (connectorId: string, digest: string) => Promise; + readonly resolve: (connectorKey: string) => Promise; + readonly service: ConnectorInstallService; + setCatalog: (entries: readonly ConnectorCatalogEntry[] | Error) => void; +} + +/** + * A real install service over a temp data dir. `fixtures` supplies the + * developer-local store; the catalog is whatever the test sets, never the + * network. + */ +function harness(): Harness { + const dataDir = mkdtempSync(join(tmpdir(), "pdpp-readiness-")); + let catalog: readonly ConnectorCatalogEntry[] | Error = []; + let pending: ConnectorCatalogEntry | undefined; + const service = createConnectorInstallService({ + catalogLoader: async () => { + if (catalog instanceof Error) { + throw catalog; + } + return catalog; + }, + dataDir, + installArtifact: (root) => { + if (!pending) { + throw new Error("no catalog entry is being installed"); + } + writeArtifact(root, pending.connector_id, pending.version ?? "1.0.0", (pending.bindings ?? {}) as Record); + }, + localSourceStore: createFileLocalConnectorSourceStore(process.env.PDPP_DATA_DIR), + registerManifest: () => Promise.resolve(), + store: createFileConnectorInstallStore(dataDir), + }); + const resolver = createBrowserEnrollmentReadinessResolver({ installService: service }); + return { + dataDir, + install: async (connectorId, digest) => { + pending = Array.isArray(catalog) ? catalog.find((entry) => entry.digest === digest) : undefined; + await service.install(connectorId, digest); + }, + resolve: (connectorKey) => resolver.resolve(connectorKey), + service, + setCatalog: (next) => { + catalog = next; + }, + }; +} + +async function withHarness( + run: (h: Harness, fixtures: ReturnType) => Promise, + options: { browserHost?: boolean } = {} +): Promise { + const fixtures = createBrowserArtifactFixtures(); + const restoreHost = setBrowserHostCapability(options.browserHost ?? true); + const h = harness(); + try { + await run(h, fixtures); + } finally { + restoreHost(); + fixtures.close(); + rmSync(h.dataDir, { force: true, recursive: true }); + } +} + +function browserManifest(connectorId: string): Record { + return { + connector_id: connectorId, + connector_key: connectorId, + display_name: connectorId, + runtime_requirements: { bindings: BROWSER_BINDINGS }, + streams: [{ name: "orders", primary_key: ["id"], schema: { properties: { id: { type: "string" } }, type: "object" } }], + version: "1.0.0", + }; +} + +// ─── Catalog × no artifact ────────────────────────────────────────────────── + +test("published but not installed: install, with the catalog as the reason it is offered", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "install"); + assert.equal(readiness.reason, "not_installed"); + assert.equal(readiness.catalog, "offered"); + assert.equal(readiness.artifact.state, "none"); + assert.equal(readiness.browser_bound, true); + }); +}); + +test("published connector that does not use a browser is never offered browser setup", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-api", DIGEST_V1, "1.0.0", NETWORK_BINDINGS)]); + const readiness = await h.resolve("acme-api"); + assert.equal(readiness.action, "unavailable"); + assert.equal(readiness.reason, "not_browser_bound"); + assert.equal(readiness.browser_bound, false); + }); +}); + +test("in no catalog and nothing installed: unavailable, not published", async () => { + await withHarness(async (h) => { + h.setCatalog([]); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "unavailable"); + assert.equal(readiness.reason, "not_published"); + assert.equal(readiness.catalog, "not_offered"); + }); +}); + +test("catalog unreachable and nothing installed: retry, not a claim that it does not exist", async () => { + await withHarness(async (h) => { + h.setCatalog(new Error("ghcr.io is unreachable")); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "retry"); + assert.equal(readiness.reason, "catalog_unavailable"); + assert.equal(readiness.catalog, "unavailable"); + }); +}); + +// ─── Installed artifact ───────────────────────────────────────────────────── + +test("an installed, verified browser artifact on a browser host: connect", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "connect"); + assert.equal(readiness.reason, "runnable"); + assert.equal(readiness.host, "ready"); + assert.deepEqual( + { source: readiness.artifact.source, state: readiness.artifact.state, version: readiness.artifact.version }, + { source: "installed", state: "verified", version: "1.0.0" } + ); + }); +}); + +test("an older installed version stays runnable when the catalog offers a newer one", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + h.setCatalog([catalogEntry("acme-shop", DIGEST_V2, "2.0.0")]); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "connect"); + assert.equal(readiness.artifact.version, "1.0.0"); + // The catalog is not consulted once an artifact is selected. + assert.equal(readiness.catalog, "not_consulted"); + }); +}); + +test("an installed connector stays runnable while the catalog is down", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + h.setCatalog(new Error("ghcr.io is unreachable")); + assert.equal((await h.resolve("acme-shop")).action, "connect"); + }); +}); + +test("an installed artifact whose bytes changed: repair, never connect", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + const [record] = await h.service.status(); + assert.ok(record); + appendFileSync(join(record.root, record.entrypointPath), "// tampered\n"); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "repair"); + assert.equal(readiness.reason, "artifact_invalid"); + assert.equal(readiness.artifact.state, "invalid"); + assert.ok(readiness.detail, "the owner sees why"); + }); +}); + +test("an installed artifact on a host with no browser surface: configure, with the host's reason", async () => { + await withHarness( + async (h) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "configure"); + assert.equal(readiness.reason, "host_requirement_unmet"); + assert.equal(readiness.host, "blocked"); + assert.match(String(readiness.detail), /browser runtime is not configured/); + }, + { browserHost: false } + ); +}); + +test("an installed artifact that does not use a browser: unavailable for browser setup", async () => { + await withHarness(async (h) => { + h.setCatalog([catalogEntry("acme-api", DIGEST_V1, "1.0.0", NETWORK_BINDINGS)]); + await h.install("acme-api", DIGEST_V1); + const readiness = await h.resolve("acme-api"); + assert.equal(readiness.action, "unavailable"); + assert.equal(readiness.reason, "not_browser_bound"); + }); +}); + +// ─── Developer-local source ───────────────────────────────────────────────── + +test("a runnable unpublished developer-local source: connect, whatever the catalog says", async () => { + await withHarness(async (h, fixtures) => { + h.setCatalog([]); + await fixtures.publish(browserManifest("acme-local")); + const readiness = await h.resolve("acme-local"); + assert.equal(readiness.action, "connect"); + assert.equal(readiness.artifact.source, "developer_local"); + assert.equal(readiness.catalog, "not_consulted"); + }); +}); + +test("a developer-local source that changed on disk blocks, even over a valid install", async () => { + await withHarness(async (h, fixtures) => { + h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); + await h.install("acme-shop", DIGEST_V1); + await fixtures.publish(browserManifest("acme-shop")); + fixtures.corrupt("acme-shop"); + const readiness = await h.resolve("acme-shop"); + assert.equal(readiness.action, "repair"); + assert.equal(readiness.artifact.source, "developer_local"); + }); +}); + +// ─── Inspection failure ───────────────────────────────────────────────────── + +test("a store that cannot be read: retry, and the cause is shown", async () => { + const service = { + catalog: async () => [], + inspectSelected: async () => { + throw new Error("sqlite is locked"); + }, + }; + const readiness = await createBrowserEnrollmentReadinessResolver({ installService: service }).resolve("acme-shop"); + assert.equal(readiness.action, "retry"); + assert.equal(readiness.reason, "inspection_unavailable"); + assert.equal(readiness.detail, "sqlite is locked"); +}); + +test("a corrupted artifact is still reported browser-bound from the registered manifest", async () => { + const service = { + catalog: async () => [], + inspectSelected: async () => ({ reason: "bytes changed", source: "installed" as const, status: "invalid" as const }), + }; + const withManifest = createBrowserEnrollmentReadinessResolver({ + installService: service, + registeredManifest: async () => browserManifest("acme-shop"), + }); + const without = createBrowserEnrollmentReadinessResolver({ installService: service }); + assert.equal((await withManifest.resolve("acme-shop")).browser_bound, true); + assert.equal((await without.resolve("acme-shop")).browser_bound, false); + // resolveMany answers exactly what resolve answers. + assert.deepEqual(await withManifest.resolveMany(["acme-shop"]), [await withManifest.resolve("acme-shop")]); +}); + +// ─── Pure table ───────────────────────────────────────────────────────────── + +test("tier, roster membership and the connector's name are not inputs", async () => { + await withHarness(async (h) => { + // The same artifact under a known-scaffold name and an invented one. + h.setCatalog([ + catalogEntry("anthropic", DIGEST_V1, "1.0.0"), + catalogEntry("zzz-unknown", DIGEST_V2, "1.0.0"), + ]); + await h.install("anthropic", DIGEST_V1); + await h.install("zzz-unknown", DIGEST_V2); + const [a, b] = await Promise.all([h.resolve("anthropic"), h.resolve("zzz-unknown")]); + assert.equal(a.action, "connect"); + assert.equal(b.action, "connect"); + }); +}); + +test("projection rows are evaluated top to bottom: an invalid artifact beats the catalog and the host", () => { + const projected = projectBrowserEnrollmentReadiness({ + artifact: { reason: "bytes changed", source: "installed", status: "invalid" }, + catalog: "offered", + catalogBrowserBound: true, + connectorKey: "acme-shop", + hintBrowserBound: true, + host: { ready: false, reason: "no browser" }, + }); + assert.equal(projected.action, "repair"); + assert.equal(projected.host, "not_evaluated"); +}); + +test("a registered manifest alone is not an artifact: with no catalog read it is not installed", () => { + const projected = projectBrowserEnrollmentReadiness({ + artifact: { status: "none" }, + catalog: "not_consulted", + catalogBrowserBound: null, + connectorKey: "acme-shop", + hintBrowserBound: true, + host: null, + }); + assert.equal(projected.action, "unavailable"); + assert.equal(projected.reason, "not_installed"); + assert.equal(projected.browser_bound, true); +}); diff --git a/reference-implementation/test/connector-registry-manifest-derivation.test.ts b/reference-implementation/test/connector-registry-manifest-derivation.test.ts index 540fb3f7c..a3f53eca8 100644 --- a/reference-implementation/test/connector-registry-manifest-derivation.test.ts +++ b/reference-implementation/test/connector-registry-manifest-derivation.test.ts @@ -247,12 +247,13 @@ test("counterweight: a custom third-party connector_id, unknown to every generat // runtime_requirements.bindings.browser, not from BROWSER_BOUND_CONNECTORS // naming this id. const browserBound = buildConnectionSetupPlan({ + browserEnrollmentRunnable: true, connectorKey: thirdPartyId, manifest: customManifest(thirdPartyId, { browser: { required: true } }), }); assert.equal(browserBound.connectorModality, "browser_bound"); - // Not a known scaffold, so the shared browserEnrollmentSupport rule offers - // browser setup exactly as it would for a bundled connector. + // With a runnable readiness verdict the planner offers browser setup exactly + // as it would for a bundled connector. assert.equal(browserBound.catalogDisposition, "browser_collector_manual"); // A third-party static-secret connector with a real credential_capture diff --git a/reference-implementation/test/helpers/browser-artifact-fixtures.ts b/reference-implementation/test/helpers/browser-artifact-fixtures.ts new file mode 100644 index 000000000..8187c6ce6 --- /dev/null +++ b/reference-implementation/test/helpers/browser-artifact-fixtures.ts @@ -0,0 +1,101 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { canonicalConnectorKey, classifyConnectorIntentModality } from "../../server/connection-setup-plan.ts"; +import { createFileLocalConnectorSourceStore } from "../../server/connector-install/local-source.ts"; + +const BROWSER_HOST_ENV = [ + "PDPP_ALLOW_UNMANAGED_BROWSER_SCHEDULES", + "PDPP_BROWSER_SURFACE_HOST_ENDPOINT", + "PDPP_BROWSER_SURFACE_MODE", + "PDPP_BROWSER_SURFACE_REMOTE_CDP_URL", + "PDPP_NEKO_CDP_HTTP_URL", + "PDPP_NEKO_MANAGED_CONNECTORS", + "PDPP_RUNTIME_BROWSER", +] as const; + +/** + * Sets whether this test process looks like a host with a browser surface, by + * the same env the scheduler's host rules read. Returns the restore function. + */ +export function setBrowserHostCapability(configured: boolean): () => void { + const saved = BROWSER_HOST_ENV.map((name) => [name, process.env[name]] as const); + for (const name of BROWSER_HOST_ENV) { + delete process.env[name]; + } + if (configured) { + process.env.PDPP_ALLOW_UNMANAGED_BROWSER_SCHEDULES = "1"; + } + return () => { + for (const [name, value] of saved) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + }; +} + +/** Synthetic executable fixtures for route tests; registration alone is not code availability. */ +export function createBrowserArtifactFixtures(): { + readonly close: () => void; + readonly corrupt: (connectorKey: string) => void; + readonly publish: (manifest: Record) => Promise>; +} { + const entrypoints = new Map(); + const root = mkdtempSync(join(tmpdir(), "browser-artifact-fixtures-")); + const previous = process.env.PDPP_DATA_DIR; + process.env.PDPP_DATA_DIR = root; + const store = createFileLocalConnectorSourceStore(root); + return { + corrupt(connectorKey) { + const entrypoint = entrypoints.get(canonicalConnectorKey(connectorKey)); + if (!entrypoint) { + throw new Error(`No published fixture for ${connectorKey}`); + } + appendFileSync(entrypoint, "// changed after it was loaded\n"); + }, + close() { + if (previous === undefined) { + delete process.env.PDPP_DATA_DIR; + } else { + process.env.PDPP_DATA_DIR = previous; + } + rmSync(root, { force: true, recursive: true }); + }, + async publish(manifest) { + if (classifyConnectorIntentModality(manifest) !== "browser_bound") { + return manifest; + } + const rawKey = manifest.connector_key ?? manifest.connector_id; + if (typeof rawKey !== "string") { + throw new Error("Browser fixture needs a connector key"); + } + const key = canonicalConnectorKey(rawKey); + const provenance = `https://fixtures.pdpp.test/connectors/${encodeURIComponent(key)}`; + const registered = { + ...manifest, + connector_key: key, + manifest_uri: + manifest.manifest_uri ?? (URL.canParse(String(manifest.connector_id)) ? manifest.connector_id : provenance), + version: manifest.version ?? "1.0.0", + }; + const path = mkdtempSync(join(root, "payload-")); + mkdirSync(join(path, "profile")); + mkdirSync(join(path, "dist")); + writeFileSync( + join(path, "profile/collection-profile.json"), + JSON.stringify({ ...registered, connector_id: provenance }) + ); + writeFileSync(join(path, "dist/collection-profile.mjs"), "export {};\n"); + entrypoints.set(key, join(path, "dist/collection-profile.mjs")); + const source = await store.add(path); + await store.select(key, source.sourceId); + return registered; + }, + }; +} diff --git a/reference-implementation/test/owner-connector-templates-projection-isolation.test.ts b/reference-implementation/test/owner-connector-templates-projection-isolation.test.ts index 836217d07..cf99c54a0 100644 --- a/reference-implementation/test/owner-connector-templates-projection-isolation.test.ts +++ b/reference-implementation/test/owner-connector-templates-projection-isolation.test.ts @@ -87,6 +87,7 @@ function mountAndCaptureHandler(manifests: Record[]): CapturedR }, listRegisteredConnectorIds: async () => Array.from(byId.keys()), projectStorageDisplayName: (displayName) => displayName ?? null, + readinessResolver: { resolve: async () => assert.fail("unexpected readiness read"), resolveMany: async () => [] }, requireOwner: (...args: unknown[]) => (args[2] as () => void)(), requireToken: (...args: unknown[]) => (args[2] as () => void)(), resolveResource: () => "http://localhost", diff --git a/reference-implementation/test/owner-connector-templates.test.ts b/reference-implementation/test/owner-connector-templates.test.ts index 641ad5bad..27325238d 100644 --- a/reference-implementation/test/owner-connector-templates.test.ts +++ b/reference-implementation/test/owner-connector-templates.test.ts @@ -16,9 +16,12 @@ import test from "node:test"; import { readPolyfillManifests } from "@pdpp/polyfill-connectors/manifests"; import { canonicalConnectorKey } from "../server/connector-key.ts"; +import { createConnectorInstallService } from "../server/connector-install/index.ts"; +import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; import { startServer } from "../server/index.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; import { TEST_PRE_REGISTERED_PUBLIC_CLIENTS } from "./fixtures/demo-clients.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; const OWNER_SUBJECT_ID = "owner_local"; const NOW = "2026-06-01T00:00:00.000Z"; @@ -74,8 +77,17 @@ async function withServer( fn: (ctx: { asUrl: string; rsUrl: string }) => Promise, options: { configuredProviderAuthConnectorKeys?: readonly string[] } = {} ): Promise { + // Registration alone is not code availability: browser-bound connectors get a + // verified developer-local artifact (see `registerConnector`) and the host + // looks browser-capable. The signed catalog is stubbed empty; no network. + fixtures = createBrowserArtifactFixtures(); + const restoreHost = setBrowserHostCapability(true); const server = await startServer({ asPort: 0, + connectorInstallService: createConnectorInstallService({ + ...installedConnectorManifestRegistration, + catalogLoader: async () => [], + }), ...(options.configuredProviderAuthConnectorKeys === undefined ? {} : { configuredProviderAuthConnectorKeys: options.configuredProviderAuthConnectorKeys }), @@ -91,6 +103,9 @@ async function withServer( await fn({ asUrl, rsUrl }); } finally { await closeServer(server); + restoreHost(); + fixtures?.close(); + fixtures = null; } } @@ -191,15 +206,22 @@ function loadManifest(name: string): Record { return entry.manifest as Record; } -async function registerConnector(asUrl: string, manifest: Record): Promise> { +let fixtures: ReturnType | null = null; + +async function registerConnector( + asUrl: string, + manifest: Record, + options: { publish?: boolean } = {} +): Promise> { + const registered = options.publish === false ? manifest : ((await fixtures?.publish(manifest)) ?? manifest); const resp = await fetch(`${asUrl}/connectors`, { - body: JSON.stringify(manifest), + body: JSON.stringify(registered), headers: { "Content-Type": "application/json" }, method: "POST", }); const responseBody = await resp.text(); assert.equal(resp.status, 201, `register ${manifest.connector_id} failed: ${resp.status} ${responseBody}`); - return manifest; + return registered; } interface SeedInstanceInput { @@ -252,7 +274,8 @@ test("owner-agent bearer lists connector templates with related connection summa ...asRecord(listedUnprovenManifest.capabilities), public_listing: { tier: "preview" }, }; - await registerConnector(asUrl, listedUnprovenManifest); + // Registered only: no artifact is installed, so no browser setup is runnable. + await registerConnector(asUrl, listedUnprovenManifest, { publish: false }); const amazonKey = canonicalConnectorKey(amazonManifest.connector_id); assert.ok(amazonKey, "amazon manifest must resolve a canonical connector key"); await seedInstance({ @@ -331,7 +354,7 @@ test("owner-template projection separates browser owner-session setup from owner ...asRecord(browserRunbookManifest.capabilities), public_listing: { tier: "supported" }, }; - await registerConnector(asUrl, browserRunbookManifest); + await registerConnector(asUrl, browserRunbookManifest, { publish: false }); const ownerToken = await issueOwnerToken(asUrl); const { status, body } = await fetchJson(`${rsUrl}/v1/owner/connector-templates`, { @@ -771,7 +794,7 @@ test("unproven with recognized modality but non-actionable plan is not UAT-expos ...asRecord(doorDashManifest.capabilities), public_listing: { tier: "development" }, }; - await registerConnector(asUrl, doorDashManifest); + await registerConnector(asUrl, doorDashManifest, { publish: false }); const ownerToken = await issueOwnerToken(asUrl); const { status, body } = await fetchJson(`${rsUrl}/v1/owner/connector-templates`, { @@ -885,7 +908,7 @@ test("UAT allowlist: development connector exposed only when flag+key+valid-setu if (scenario.connectorKey === "doordash") { const m = loadManifest("doordash"); m.capabilities = { ...asRecord(m.capabilities), public_listing: { tier: "development" } }; - await registerConnector(asUrl, m); + await registerConnector(asUrl, m, { publish: false }); } if (scenario.connectorKey === "steam") { await registerConnector(asUrl, loadManifest("steam")); From 436760341901132e3b90d802f0af2c58889a6eea Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 11:49:04 -0600 Subject: [PATCH 02/17] fix(console): read the server's browser-setup verdict instead of deriving it The console ran the same roster-dependent rule in its own process, so the picker and the server could answer differently from the same inputs. A published, installable connector (LinkedIn, Goodreads) read "Not built yet" and its Install control was hidden. The owner template now carries the server's readiness verdict (connector_readiness), and the browser-session routes fetch it from GET /v1/owner/connector-install/readiness. The console parses it at the boundary, drops a malformed verdict, and lets only a "connect" action add an account. A blocked verdict is explained next to the package control, which stays available so an invalid package can be reinstalled. A manifest alone grants no browser enrollment, so the manifest-only fallback catalog fails closed. A server that predates the endpoint answers 404; the console then defers to that server's own enrollment check instead of blocking every browser connector. Removes the console's browser-connect-support rule, the isKnownScaffold entry field and the "Not built yet" copy. The picker test that compared the card to browserEnrollmentSupport now asserts the picker's action follows the verdict. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 48 +++++-- .../components/source-setup-catalog.tsx | 17 ++- .../lib/browser-connect-readiness.ts | 32 +++++ .../browser-connect-support.no-roster.test.ts | 48 ------- .../lib/browser-connect-support.test.ts | 87 ------------ .../(console)/lib/browser-connect-support.ts | 59 -------- .../(console)/lib/connection-catalog.test.ts | 60 +++++--- .../app/(console)/lib/connection-catalog.ts | 29 ++-- .../(console)/lib/connector-install-client.ts | 9 ++ .../connector-install-presentation.test.ts | 2 +- .../lib/connector-readiness-contract.test.ts | 119 ++++++++++++++++ .../lib/connector-readiness-contract.ts | 84 +++++++++++ .../(console)/lib/connector-readiness-copy.ts | 32 +++++ .../lib/load-browser-connect-support.ts | 35 ++--- .../lib/picker-readiness-verdict.test.ts | 134 ++++++++++++++++++ .../lib/picker-shared-connect-rule.test.ts | 71 ---------- .../(console)/lib/source-add-support.test.ts | 23 +-- .../lib/source-setup-card-action.test.ts | 2 +- .../lib/source-setup-presentation.test.ts | 67 ++++----- .../lib/source-setup-presentation.ts | 58 +++----- .../sources/add/add-source-demo-data.ts | 26 ++-- 21 files changed, 589 insertions(+), 453 deletions(-) create mode 100644 apps/console/src/app/(console)/lib/browser-connect-readiness.ts delete mode 100644 apps/console/src/app/(console)/lib/browser-connect-support.no-roster.test.ts delete mode 100644 apps/console/src/app/(console)/lib/browser-connect-support.test.ts delete mode 100644 apps/console/src/app/(console)/lib/browser-connect-support.ts create mode 100644 apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts create mode 100644 apps/console/src/app/(console)/lib/connector-readiness-contract.ts create mode 100644 apps/console/src/app/(console)/lib/connector-readiness-copy.ts create mode 100644 apps/console/src/app/(console)/lib/picker-readiness-verdict.test.ts delete mode 100644 apps/console/src/app/(console)/lib/picker-shared-connect-rule.test.ts diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index 95f03345e..328047b00 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -36,7 +36,7 @@ const reddit: ConnectorCatalogEntry = { displayName: "Reddit", disposition: "browser_collector_manual", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "browser_bound", nextStepKind: "enroll_browser_collector", @@ -87,7 +87,6 @@ const UNAVAILABLE_TRUE = /data-row-unavailable="true"/; const UNAVAILABLE_DISCLOSURE = /data-testid="unavailable-connectors-disclosure"/; const BROWSER_SETUP_LINK = /href="\/connect\/browser-session\//; const INSTALL_OR_BROWSER_SETUP = />Install<\/button>|href="\/connect\/browser-session\//; -const INSTALL_DISABLED = /data-testid="connector-install-disabled"/; const HOST_BLOCK = /Host blocked: Browser unavailable here\./; const CANNOT_ADD_HERE = /1 connector cannot be added here/; const PLATFORM_COPY = /not available on this platform/; @@ -131,19 +130,50 @@ test("an unregistered browser connector offers Install before account setup exis assert.doesNotMatch(html, BROWSER_SETUP_LINK, "installation must not invent a working setup route"); }); -test("known scaffolds still offer neither Install nor account setup", async () => { +const verdict = ( + reason: NonNullable["reason"], + action: NonNullable["action"], + detail: string | null = null +): NonNullable => ({ + action, + browserBound: true, + connectorKey: "new-browser", + detail, + reason, +}); +const READINESS_NOTE = /data-testid="connector-readiness-note"/; +const NOT_BUILT_YET = /Not built yet/; + +test("a published, uninstalled browser connector offers Install and is never 'Not built yet'", async () => { + // The defect this replaces: LinkedIn and Goodreads are published and + // installable, but a vendored roster made them read "Not built yet". + const html = await render( + false, + { ...unregisteredBrowser, readiness: verdict("not_installed", "install") }, + browserPackage + ); + assert.match(html, INSTALL_BUTTON); + assert.doesNotMatch(html, NOT_BUILT_YET); + assert.doesNotMatch(html, READINESS_NOTE); + assert.match(html, UNAVAILABLE_FALSE); +}); + +test("a verdict that blocks setup shows its reason and offers no account setup", async () => { + const [installed] = parseConnectorInstallStatusResponse(connectorInstallStatusFixture).data; + assert.ok(installed); + assert.ok(browserPackage.catalog); const html = await render( false, { ...unregisteredBrowser, - disposition: "browser_collector_manual", - isKnownScaffold: true, - ownerActionable: true, + readiness: verdict("host_requirement_unmet", "configure", "No browser surface is configured."), }, - browserPackage + { catalog: { ...browserPackage.catalog, digest: installed.digest }, installed: { ...installed, connector_id: "new-browser" } } ); - assert.match(html, INSTALL_DISABLED); - assert.doesNotMatch(html, INSTALL_OR_BROWSER_SETUP); + assert.match(html, READINESS_NOTE); + assert.match(html, /No browser surface is configured\./); + assert.doesNotMatch(html, BROWSER_SETUP_LINK); + assert.doesNotMatch(html, NOT_BUILT_YET); }); // Installing a package registers a connector but cannot create a setup path: diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.tsx b/apps/console/src/app/(console)/components/source-setup-catalog.tsx index ad54d5243..3d2743e17 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.tsx +++ b/apps/console/src/app/(console)/components/source-setup-catalog.tsx @@ -12,6 +12,7 @@ import { ConnectorMark } from "./connector-mark.tsx"; import { OpenExternalLink } from "./open-external-link.tsx"; import type { ConnectorAcquisitionPath, ConnectorCatalogEntry } from "../lib/connection-catalog.ts"; import type { ConnectorInstallLifecycle } from "../lib/connector-install-presentation.ts"; +import { readinessBlockNote } from "../lib/connector-readiness-copy.ts"; import { connectorLookupKey, isUninstallableCatalogEntry, @@ -144,16 +145,13 @@ function SourceAcquisitionPaths({ paths }: { paths: readonly ConnectorAcquisitio * Development-tier method line, resolved before the `not_available_here` * short-circuit below. `sourceSetupAvailability` reports `not_available_here` * for every Development entry regardless of whether it renders an add - * action (see `sourceSetupAction`'s scaffold check), so that check alone + * action (see `sourceSetupAction`'s disposition check), so that check alone * would print "No proven setup path is available" directly above a real * "Add account" button for a self-testable entry -- contradicting the * action right next to it. */ function developmentMethodLine(entry: ConnectorCatalogEntry): string { - if (entry.isKnownScaffold) { - return "Not built yet: nobody has written this connector's collection code."; - } - return "Setup path implemented, not yet proven against a live account."; + return readinessBlockNote(entry.readiness) ?? "Setup path implemented, not yet proven against a live account."; } function sourceMethodLine(entry: ConnectorCatalogEntry, existingSourceCount: number): string { @@ -509,11 +507,12 @@ function SourceSetupCard({

Package

- {entry.isKnownScaffold ? ( - - This connector's collection code is not built yet. + {readinessBlockNote(entry.readiness) ? ( + + {readinessBlockNote(entry.readiness)} - ) : noSetupPath ? ( + ) : null} + {noSetupPath ? ( Not installable: no setup path is available here. diff --git a/apps/console/src/app/(console)/lib/browser-connect-readiness.ts b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts new file mode 100644 index 000000000..f902a2ed1 --- /dev/null +++ b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts @@ -0,0 +1,32 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import { isBrowserBoundConnector } from "pdpp-reference-implementation/connection-setup-plan"; +import type { ConnectorReadiness } from "./connector-readiness-contract.ts"; + +export interface BrowserConnectSupport { + /** The connector uses the browser-session flow. A bound connector keeps the routes for repair. */ + readonly browserBound: boolean; + /** The server says the owner can add an account now. */ + readonly canAddAccount: boolean; +} + +/** + * Pure mapping from the server's verdict. A missing verdict permits no new + * account, except from a server that predates the readiness endpoint (an older + * server next to a newer console): that server applies its own rule when the + * enrollment route is called, so the console defers to it instead of blocking + * every browser connector. + */ +export function browserConnectSupportFromReadiness( + connectorId: string, + readiness: ConnectorReadiness | null, + serverPredatesReadiness = false +): BrowserConnectSupport { + return { + // A connector the bundled manifest marks browser-bound keeps the routes for + // repair of an existing connection, even when the verdict is unavailable. + browserBound: readiness?.browserBound === true || isBrowserBoundConnector(connectorId), + canAddAccount: readiness ? readiness.action === "connect" : serverPredatesReadiness, + }; +} diff --git a/apps/console/src/app/(console)/lib/browser-connect-support.no-roster.test.ts b/apps/console/src/app/(console)/lib/browser-connect-support.no-roster.test.ts deleted file mode 100644 index 0fabb938b..000000000 --- a/apps/console/src/app/(console)/lib/browser-connect-support.no-roster.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { test } from "node:test"; -import { fileURLToPath } from "node:url"; - -// Every @pdpp/polyfill-connectors subpath fails to resolve, from any parent, -// as in a packaged console whose bundle cannot find the package. -const NO_ROSTER_HOOK = ` -import { registerHooks } from "node:module"; -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier.startsWith("@pdpp/polyfill-connectors/")) { - throw Object.assign(new Error("Cannot find module '" + specifier + "' (simulated packaged console)"), { - code: "MODULE_NOT_FOUND", - }); - } - return nextResolve(specifier, context); - }, -}); -`; - -test("browser-connect-support.test.ts passes without the conformance roster", () => { - const child = spawnSync( - process.execPath, - [ - "--import", - "tsx", - "--import", - `data:text/javascript,${encodeURIComponent(NO_ROSTER_HOOK)}`, - "--test", - "--test-reporter=tap", - fileURLToPath(new URL("./browser-connect-support.test.ts", import.meta.url)), - ], - { - cwd: fileURLToPath(new URL("../../../../", import.meta.url)), - encoding: "utf8", - // Without NODE_TEST_CONTEXT the child reports plain TAP, not to this runner. - env: { ...process.env, NODE_TEST_CONTEXT: undefined, PDPP_TEST_SIMULATE_NO_CONNECTOR_ROSTER: "1" }, - } - ); - assert.equal(child.status, 0, `${child.stdout}\n${child.stderr}`); - assert.match(child.stdout, /^ok \d+ - the conformance roster is loaded exactly when this run expects it$/m); - assert.match(child.stdout, /^ok \d+ - a known scaffold stays browser-bound but cannot add an account$/m); - assert.match(child.stdout, /^# fail 0$/m); -}); diff --git a/apps/console/src/app/(console)/lib/browser-connect-support.test.ts b/apps/console/src/app/(console)/lib/browser-connect-support.test.ts deleted file mode 100644 index 6eb400588..000000000 --- a/apps/console/src/app/(console)/lib/browser-connect-support.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { test } from "node:test"; -import { - classifyConnectorIntentModality, - isKnownScaffoldConnector, -} from "pdpp-reference-implementation/connection-setup-plan"; -import { type BindingSource, browserConnectSupport } from "./browser-connect-support.ts"; -import { isSupportedBrowserCollectorConnector } from "./connection-modality.ts"; -import { parseConnectorInstallStatusResponse } from "./connector-install-contract.ts"; -import { connectorInstallStatusFixture } from "./connector-install-fixtures.ts"; - -const BROWSER_SESSION_DIR = new URL("../connect/browser-session/[connectorId]/", import.meta.url); - -// browser-connect-support.no-roster.test.ts reruns this file with the -// optional conformance roster made unresolvable, as in a packaged console that -// cannot load @pdpp/polyfill-connectors. Then no connector can add an account. -const ROSTER_ABSENT = process.env.PDPP_TEST_SIMULATE_NO_CONNECTOR_ROSTER === "1"; -const CAN_ADD_ACCOUNT = !ROSTER_ABSENT; - -test("the conformance roster is loaded exactly when this run expects it", () => { - assert.equal(isKnownScaffoldConnector("anthropic"), !ROSTER_ABSENT); -}); - -// Installed packages as the connector-install status route reports them. The -// last one is a catalog-only browser-bound connector that no key list in the -// console knows about. -const installed: BindingSource[] = [ - ...parseConnectorInstallStatusResponse(connectorInstallStatusFixture).data, - { bindings: { browser: { available: true }, network: { available: true } }, connector_id: "reddit" }, - { bindings: { browser: { available: true } }, connector_id: "https://registry.pdpp.dev/connectors/amazon" }, - { bindings: { browser: { available: true } }, connector_id: "acme-shop" }, -]; - -function isBrowserBinding(source: BindingSource): boolean { - return ( - classifyConnectorIntentModality({ connector_id: source.connector_id, runtime_requirements: { bindings: source.bindings } }) === - "browser_bound" - ); -} - -test("every installed browser-bound connector can add an account", () => { - const browserBound = installed.filter(isBrowserBinding); - assert.equal(browserBound.length, 3); - for (const source of browserBound) { - assert.deepEqual( - browserConnectSupport(source.connector_id, installed), - { browserBound: true, canAddAccount: CAN_ADD_ACCOUNT }, - source.connector_id - ); - } -}); - -test("an installed browser-bound connector outside the old key list can add an account", () => { - // The old console gate read a connector-key list, which rejected this one. - assert.equal(isSupportedBrowserCollectorConnector("acme-shop"), false); - assert.equal(browserConnectSupport("acme-shop", installed).canAddAccount, CAN_ADD_ACCOUNT); -}); - -test("a registry-URL connector id matches the installed bare key", () => { - assert.equal(browserConnectSupport("https://registry.pdpp.dev/connectors/reddit", installed).canAddAccount, CAN_ADD_ACCOUNT); - assert.equal(browserConnectSupport("amazon", installed).canAddAccount, CAN_ADD_ACCOUNT); -}); - -test("connectors without a browser binding do not use the browser-session flow", () => { - assert.deepEqual(browserConnectSupport("github", installed), { browserBound: false, canAddAccount: false }); - assert.deepEqual(browserConnectSupport("imessage", installed), { browserBound: false, canAddAccount: false }); - assert.deepEqual(browserConnectSupport("not-installed", installed), { browserBound: false, canAddAccount: false }); - assert.deepEqual(browserConnectSupport("", installed), { browserBound: false, canAddAccount: false }); - assert.deepEqual(browserConnectSupport(null, installed), { browserBound: false, canAddAccount: false }); -}); - -test("a known scaffold stays browser-bound but cannot add an account", () => { - const sources = [{ bindings: { browser: {} }, connector_id: "anthropic" }]; - assert.deepEqual(browserConnectSupport("anthropic", sources), { browserBound: true, canAddAccount: false }); -}); - -test("browser-session routes gate on the binding-derived support, not a key list", () => { - for (const file of ["page.tsx", "start/route.ts", "launch/page.tsx", "launch/start/route.ts", "launch/recover/route.ts"]) { - const src = readFileSync(new URL(file, BROWSER_SESSION_DIR), "utf8"); - assert.match(src, /loadBrowserConnectSupport\(connectorId\)/, file); - assert.doesNotMatch(src, /isSupportedBrowserCollectorConnector|isBrowserBoundConnector/, file); - } -}); diff --git a/apps/console/src/app/(console)/lib/browser-connect-support.ts b/apps/console/src/app/(console)/lib/browser-connect-support.ts deleted file mode 100644 index 355c68363..000000000 --- a/apps/console/src/app/(console)/lib/browser-connect-support.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -/** - * Browser-session setup support, derived from the connector's own runtime - * bindings. - * - * A connector supports "Connect account" in the browser-session flow when an - * installed package (or a local manifest) declares a `browser` binding. The - * decision is the shared `browserEnrollmentSupport` rule that the RI's - * enrollment-shell route applies to the registered manifest, so the console - * and the RI give the same answer. Known scaffolds stay excluded: they can - * never collect. Without the conformance roster that rule adds no account. - * - * The console must not gate this on a connector-key list. A key list cannot - * know about a connector that the owner installed from the runtime catalog, - * and the production-ready roster that the shared list reads is an optional - * module that a packaged console cannot always load. - */ - -import { - type BrowserEnrollmentSupport, - browserEnrollmentSupport, - canonicalConnectorKey, -} from "pdpp-reference-implementation/connection-setup-plan"; - -export interface BindingSource { - readonly connector_id: string; - readonly connector_key?: string | null; - readonly bindings: Readonly> | null | undefined; -} - -export type BrowserConnectSupport = BrowserEnrollmentSupport; - -function sourceKey(source: BindingSource): string { - return canonicalConnectorKey(source.connector_key ?? source.connector_id); -} - -export function browserConnectSupport( - connectorId: string | null | undefined, - sources: readonly BindingSource[] -): BrowserConnectSupport { - if (typeof connectorId !== "string" || connectorId.trim() === "") { - return { browserBound: false, canAddAccount: false }; - } - const key = canonicalConnectorKey(connectorId); - const supports = sources - .filter((source) => sourceKey(source) === key) - .map((source) => - browserEnrollmentSupport(key, { - connector_id: source.connector_id, - runtime_requirements: { bindings: source.bindings ?? null }, - }) - ); - return { - browserBound: supports.some((support) => support.browserBound), - canAddAccount: supports.some((support) => support.canAddAccount), - }; -} diff --git a/apps/console/src/app/(console)/lib/connection-catalog.test.ts b/apps/console/src/app/(console)/lib/connection-catalog.test.ts index ccc51a37b..4ee6acf69 100644 --- a/apps/console/src/app/(console)/lib/connection-catalog.test.ts +++ b/apps/console/src/app/(console)/lib/connection-catalog.test.ts @@ -404,12 +404,11 @@ test("non-browser static-secret connectors keep the existing single capture path assert.equal(entry.modality, "api_network"); assert.equal(entry.setupModality, "static_secret"); assert.equal(entry.disposition, "static_secret_connect"); - // "gmail" is real (not a known scaffold) and its disposition - // (static_secret_connect) IS in the Development disclosure's self-test + // "gmail" has a disposition (static_secret_connect) that IS in the Development disclosure's self-test // allowlist, so it gets a self-test action in the Development disclosure // even though it is not owner-actionable (the whole Development tier is // hard-excluded from ownerActionable). - assert.equal(entry.isKnownScaffold, false); + assert.equal(entry.readiness, null, "an entry built from manifests alone carries no server verdict"); assert.equal(sourceSetupAction(entry) !== null, true, "a real development entry gets a self-test action"); assert.equal(sourceSetupSecondaryAction(entry), null); assert.equal(sourceSetupStatus(entry).label, "In development"); @@ -860,7 +859,6 @@ test("configured Google provider readiness exposes the existing owner authorizat // ready-to-authorize entry gets a self-test action even though it is not // owner-actionable (the whole Development tier is hard-excluded from // ownerActionable, and sourceSetupAvailability stays "not_available_here"). - assert.equal(entry.isKnownScaffold, false); assert.equal(sourceSetupAction(entry) !== null, true, "a real development entry gets a self-test action"); assert.equal(sourceSetupAvailability(entry), "not_available_here"); assert.deepEqual(providerAuthConnectEntries(catalog), [entry]); @@ -1059,9 +1057,14 @@ test("the grouping helpers partition the catalog without overlap or loss", async localCollectorEntries(catalog).length >= 6, "claude_code + codex + google_takeout + imessage + apple_photos + google_messages" ); + assert.deepEqual( + browserCollectorEntries(catalog), + [], + "a manifest alone grants no browser enrollment: that verdict comes from the server's templates" + ); assert.ok( - browserCollectorEntries(catalog).some((entry) => entry.connectorKey === "whoop"), - "whoop uses browser-collector enrollment" + browserBoundRunbookEntries(catalog).some((entry) => entry.connectorKey === "whoop"), + "whoop waits for the server's readiness verdict" ); assert.ok(browserBoundRunbookEntries(catalog).length >= 1); assert.ok(experimentalEntries(catalog).length >= 1, "wave-0807 experimental static-secret connectors"); @@ -1272,8 +1275,7 @@ test("presentation consistency: helper functions agree with ownerActionable auth // Exception: a real (non-scaffold) Development entry with a runnable // disposition also has a real action -- the Development disclosure's own // self-test opt-in -- even though isOwnerActionableEntry is false for the - // whole tier. A KNOWN scaffold never gets an action regardless of - // disposition: clicking it can never collect anything. + // whole tier. // // Exception: a browser-bound static_secret_connect entry (e.g. amazon, heb) // always has a real browser-session action regardless of live-proof status @@ -1288,7 +1290,7 @@ test("presentation consistency: helper functions agree with ownerActionable auth const isActionable = isOwnerActionableEntry(entry); const hasAction = sourceSetupAction(entry) !== null; const isDevelopmentSelfTestable = - entry.publicTier === "development" && !entry.isKnownScaffold && DEVELOPMENT_SELF_TEST_DISPOSITIONS.has(entry.disposition); + entry.publicTier === "development" && DEVELOPMENT_SELF_TEST_DISPOSITIONS.has(entry.disposition); const hasBrowserBoundStoredCredentialsSetupPath = entry.disposition === "static_secret_connect" && browserBoundWithStoredCredentials(entry); @@ -1321,12 +1323,6 @@ test("presentation consistency: helper functions agree with ownerActionable auth `${entry.connectorKey}: sourceSetupAction must match isOwnerActionableEntry (or the development self-test exception). ` + `Helper says ${isActionable}, action is ${hasAction ? "set" : "null"}, isDevelopmentSelfTestable=${isDevelopmentSelfTestable}` ); - - // A KNOWN scaffold must NEVER get an action, regardless of disposition: - // it cannot collect anything, so an add button would be a dead end. - if (entry.publicTier === "development" && entry.isKnownScaffold) { - assert.equal(hasAction, false, `${entry.connectorKey}: a known scaffold must never expose an action`); - } } }); @@ -1569,17 +1565,35 @@ test("console catalog uses the manifest tier as its sole listing authority", () assert.ok(entry); assert.equal(isRunnableAddOffer(entry), false, "UAT exposure must not override development"); - // A KNOWN scaffold never gets a self-test action, regardless of UAT exposure. - const scaffoldTemplate: OwnerConnectorTemplateLike = { - ...ownerTemplate({ connectorKey: "test-scaffold", tier: "development" }), - is_known_scaffold: true, + // The server's readiness verdict rides on the template and is parsed at the + // catalog boundary. A well-formed verdict is kept; a malformed one reads as + // no verdict. + const verdict = { + action: "install", + browser_bound: true, + connector_key: "test-installable", + detail: null, + reason: "not_installed", }; - catalog = buildOwnerConnectorCatalog([], [scaffoldTemplate]); + catalog = buildOwnerConnectorCatalog( + [], + [{ ...ownerTemplate({ connectorKey: "test-installable", tier: "development" }), connector_readiness: verdict }] + ); entry = catalog[0]; assert.ok(entry); - assert.equal(entry.isKnownScaffold, true); - assert.equal(isRunnableAddOffer(entry), false, "a scaffold is never a runnable add offer"); - assert.equal(sourceSetupAction(entry), null, "a scaffold never gets a self-test action"); + assert.deepEqual(entry.readiness, { + action: "install", + browserBound: true, + connectorKey: "test-installable", + detail: null, + reason: "not_installed", + }); + assert.equal(sourceSetupStatus(entry).label, "In development", "no verdict ever relabels a tier as 'Not built yet'"); + catalog = buildOwnerConnectorCatalog( + [], + [{ ...ownerTemplate({ connectorKey: "test-garbled", tier: "development" }), connector_readiness: { action: "launch" } }] + ); + assert.equal(catalog[0]?.readiness, null); }); test("an install-catalog entry published without a tier carries an unknown tier, not development", () => { diff --git a/apps/console/src/app/(console)/lib/connection-catalog.ts b/apps/console/src/app/(console)/lib/connection-catalog.ts index 26e92d589..11bcbf24c 100644 --- a/apps/console/src/app/(console)/lib/connection-catalog.ts +++ b/apps/console/src/app/(console)/lib/connection-catalog.ts @@ -26,12 +26,12 @@ import { classifyConnectorIntentModality, connectorKeyFromManifest, enrollmentKeyForCanonicalKey, - isKnownScaffoldConnector, manualUploadSetupFromManifest, type StaticSecretSetupFieldLike, staticSecretCredentialCaptureFromManifest, } from "pdpp-reference-implementation/connection-setup-plan"; import type { ConnectorInstallCatalogEntry } from "./connector-install-contract.ts"; +import { type ConnectorReadiness, parseConnectorReadiness } from "./connector-readiness-contract.ts"; /** * Minimal manifest shape the catalog reads. The real `ConnectorManifest` @@ -176,12 +176,12 @@ export interface OwnerConnectorTemplateLike { svg?: string | null; } | null; /** - * Server-owned fact, meaningful only for Development-tier entries: true when - * the connector-conformance roster names this connector a KNOWN scaffold - * (unconditional `SKIP_RESULT`, no real collection). A scaffold must never - * render an add action, even inside a Development disclosure. + * Server-owned browser-setup verdict (`connector-readiness.ts`): whether the + * owner can connect now, or the specific reason not. Null or absent for a + * template that is not browser-bound. Parsed at the catalog boundary; never + * derived from the key, the tier or a roster. */ - is_known_scaffold?: boolean | null; + connector_readiness?: unknown; public_listing?: { /** Owner-facing reason this connector is not offered as a runnable card yet, when declared. */ proof_gate?: string | null; @@ -293,12 +293,12 @@ export interface ConnectorCatalogEntry { /** Optional manifest-declared brand glyph; absent renders the Monogram fallback (see ConnectorIcon). */ icon?: OwnerConnectorTemplateLike["icon"]; /** - * Meaningful only for Development-tier entries: true when the connector is - * a KNOWN scaffold (unconditional `SKIP_RESULT`, no real collection) rather - * than real-but-unproven. Drives whether a Development disclosure card may - * ever render an add action for this entry. + * The server's browser-setup verdict for this connector, or null when the + * server gave none (not browser-bound, or the entry was built without the + * server's templates). Null permits no setup action beyond what the planner's + * disposition already grants. */ - isKnownScaffold: boolean; + readiness: ConnectorReadiness | null; /** * Manifest-authored explanation for the current lifecycle tier * (`public_listing.rationale` or `public_listing.proof_gate`, in that @@ -442,12 +442,12 @@ export function buildConnectorCatalog( disposition: plan.catalogDisposition, externalDocs: externalDocsFromManifest(manifest), icon: manifest.icon ?? null, - isKnownScaffold: isKnownScaffoldConnector(connectorKey), listingNote: listingNoteFromPublicListing(manifest.capabilities?.public_listing), modality: plan.connectorModality, nextStepKind: plan.nextStepKind, proofGate: plan.proofGate, publicTier: manifest.capabilities?.public_listing?.tier ?? "development", + readiness: null, refreshPolicyRationale: cleanManifestText(manifest.capabilities?.refresh_policy?.rationale), runbookPath: plan.runbookPath, setupDescription: setupCopy.description, @@ -658,10 +658,6 @@ export function buildOwnerConnectorCatalog( disposition, externalDocs: externalDocsFromManifest(manifestForCopy), icon: template.icon ?? localManifest?.icon ?? null, - isKnownScaffold: - typeof template.is_known_scaffold === "boolean" - ? template.is_known_scaffold - : isKnownScaffoldConnector(connectorKey), listingNote: listingNoteFromPublicListing(template.public_listing), modality: connectorModality, nextStepKind, @@ -670,6 +666,7 @@ export function buildOwnerConnectorCatalog( ownerActionUrl: capability.url, proofGate, publicTier: template.public_listing?.tier ?? "development", + readiness: parseConnectorReadiness(template.connector_readiness), refreshPolicyRationale: cleanManifestText(localManifest?.capabilities?.refresh_policy?.rationale), registrationStatus: template.registration_status, runbookPath: cleanManifestText(setupPlan.runbook_path), diff --git a/apps/console/src/app/(console)/lib/connector-install-client.ts b/apps/console/src/app/(console)/lib/connector-install-client.ts index aecf9f380..7dbf4b0bc 100644 --- a/apps/console/src/app/(console)/lib/connector-install-client.ts +++ b/apps/console/src/app/(console)/lib/connector-install-client.ts @@ -20,6 +20,7 @@ import { parseConnectorLocalSourcesResponse, parseConnectorInstallStatusResponse, } from "./connector-install-contract.ts"; +import { type ConnectorReadiness, parseConnectorReadinessResponse } from "./connector-readiness-contract.ts"; import { describeErrorText } from "./describe-error.ts"; import { isConnectorInstallRouteUnavailableError, @@ -105,6 +106,14 @@ export async function listConnectorLocalSources(): Promise { + const payload = await connectorInstallFetch( + `/v1/owner/connector-install/readiness?connector_id=${encodeURIComponent(connectorId)}` + ); + return parseConnectorReadinessResponse(payload)[0] ?? null; +} + /** * One read of the three install facts, or `null` when the server predates the * install route family (the page then shows no package facts at all). diff --git a/apps/console/src/app/(console)/lib/connector-install-presentation.test.ts b/apps/console/src/app/(console)/lib/connector-install-presentation.test.ts index d04797e82..6b8b2abab 100644 --- a/apps/console/src/app/(console)/lib/connector-install-presentation.test.ts +++ b/apps/console/src/app/(console)/lib/connector-install-presentation.test.ts @@ -35,7 +35,7 @@ function entry( displayName: connectorKey, disposition: "static_secret_connect", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "api_network", nextStepKind: "capture_static_secret", diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts new file mode 100644 index 000000000..4d482aab7 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -0,0 +1,119 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; +import { browserConnectSupportFromReadiness } from "./browser-connect-readiness.ts"; +import { readinessBlockNote } from "./connector-readiness-copy.ts"; +import { + type ConnectorReadiness, + parseConnectorReadiness, + parseConnectorReadinessResponse, + READINESS_ACTIONS, + READINESS_REASONS, +} from "./connector-readiness-contract.ts"; + +const wire = (overrides: Record = {}) => ({ + action: "connect", + artifact: { digest: null, source: null, state: "verified", version: "1.0.0" }, + browser_bound: true, + catalog: "not_consulted", + connector_key: "linkedin", + detail: null, + host: "ready", + reason: "runnable", + ...overrides, +}); + +const readiness = (action: ConnectorReadiness["action"], reason: ConnectorReadiness["reason"], detail: string | null = null) => + ({ action, browserBound: true, connectorKey: "linkedin", detail, reason }) satisfies ConnectorReadiness; + +test("a well-formed verdict parses; extra server fields are ignored", () => { + assert.deepEqual(parseConnectorReadiness(wire()), readiness("connect", "runnable")); +}); + +test("every server action and reason the contract names parses", () => { + for (const action of READINESS_ACTIONS) { + assert.equal(parseConnectorReadiness(wire({ action }))?.action, action); + } + for (const reason of READINESS_REASONS) { + assert.equal(parseConnectorReadiness(wire({ reason }))?.reason, reason); + } +}); + +test("a malformed verdict reads as no verdict", () => { + for (const bad of [ + null, + "connect", + [], + wire({ action: "launch" }), + wire({ reason: "because" }), + wire({ browser_bound: "yes" }), + wire({ connector_key: "" }), + wire({ connector_key: undefined }), + ]) { + assert.equal(parseConnectorReadiness(bad), null, JSON.stringify(bad)); + } +}); + +test("detail is trimmed and blank detail becomes null", () => { + assert.equal(parseConnectorReadiness(wire({ detail: " no display " }))?.detail, "no display"); + assert.equal(parseConnectorReadiness(wire({ detail: " " }))?.detail, null); + assert.equal(parseConnectorReadiness(wire({ detail: 5 }))?.detail, null); +}); + +test("the response parser keeps well-formed records and drops others", () => { + const parsed = parseConnectorReadinessResponse({ + data: [wire(), { action: "nope" }, wire({ connector_key: "goodreads", action: "install", reason: "not_installed" })], + object: "connector_browser_enrollment_readiness", + }); + assert.deepEqual( + parsed.map((item) => item.connectorKey), + ["linkedin", "goodreads"] + ); + assert.deepEqual(parseConnectorReadinessResponse({ data: [wire()], object: "something_else" }), []); + assert.deepEqual(parseConnectorReadinessResponse(null), []); +}); + +test("only a connect verdict lets the browser-session routes add an account", () => { + for (const action of READINESS_ACTIONS) { + const support = browserConnectSupportFromReadiness("linkedin", { ...readiness(action, "runnable") }); + assert.equal(support.canAddAccount, action === "connect", action); + } + assert.equal(browserConnectSupportFromReadiness("linkedin", null).canAddAccount, false, "no verdict fails closed"); +}); + +test("a server that predates the readiness endpoint is deferred to; any other missing verdict is not", () => { + assert.equal(browserConnectSupportFromReadiness("linkedin", null, true).canAddAccount, true); + assert.equal(browserConnectSupportFromReadiness("linkedin", null, false).canAddAccount, false); + assert.equal( + browserConnectSupportFromReadiness("linkedin", readiness("configure", "host_requirement_unmet"), true).canAddAccount, + false, + "a verdict always wins over the legacy deferral" + ); +}); + +test("the routes stay reachable for a browser-bound connector so an existing connection can be repaired", () => { + assert.equal(browserConnectSupportFromReadiness("some-new-connector", readiness("repair", "artifact_invalid")).browserBound, true); + assert.equal( + browserConnectSupportFromReadiness("some-new-connector", { ...readiness("unavailable", "not_browser_bound"), browserBound: false }) + .browserBound, + false + ); +}); + +test("blocking reasons have owner copy that carries the server's detail; others have none", () => { + assert.match(readinessBlockNote(readiness("configure", "host_requirement_unmet", "No display."))!, /not set up to run this connector yet\. No display\./); + assert.match(readinessBlockNote(readiness("repair", "artifact_invalid"))!, /failed verification/); + assert.match(readinessBlockNote(readiness("retry", "inspection_unavailable"))!, /Retry/); + assert.match(readinessBlockNote(readiness("retry", "catalog_unavailable"))!, /catalog could not be reached/); + assert.match(readinessBlockNote(readiness("unavailable", "not_published"))!, /not published/); + for (const quiet of [ + readiness("connect", "runnable"), + readiness("install", "not_installed"), + readiness("unavailable", "not_browser_bound"), + ]) { + assert.equal(readinessBlockNote(quiet), null, quiet.reason); + } + assert.equal(readinessBlockNote(null), null); +}); diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts new file mode 100644 index 000000000..ce2aa11cd --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts @@ -0,0 +1,84 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Wire type and boundary parsing for the server's browser-setup readiness + * verdict (`GET /v1/owner/connector-install/readiness`, and the + * `connector_readiness` field on an owner connector template). + * + * The server computes the verdict from the selected artifact, the host and the + * catalog. The console only reads it: it never derives readiness from a + * connector key, a roster or a tier, and it never re-derives it from bindings. + */ + +export const READINESS_ACTIONS = ["connect", "install", "repair", "configure", "retry", "unavailable"] as const; +export type ConnectorReadinessAction = (typeof READINESS_ACTIONS)[number]; + +export const READINESS_REASONS = [ + "runnable", + "not_installed", + "not_published", + "catalog_unavailable", + "artifact_invalid", + "inspection_unavailable", + "not_browser_bound", + "host_requirement_unmet", +] as const; +export type ConnectorReadinessReason = (typeof READINESS_REASONS)[number]; + +export interface ConnectorReadiness { + readonly action: ConnectorReadinessAction; + readonly browserBound: boolean; + readonly connectorKey: string; + /** Owner-readable cause for repair, configure and retry. Null otherwise. */ + readonly detail: string | null; + readonly reason: ConnectorReadinessReason; +} + +function isOneOf(values: readonly T[], value: unknown): value is T { + return typeof value === "string" && (values as readonly string[]).includes(value); +} + +/** + * One readiness record, or null when the value is not a well-formed verdict. + * A malformed verdict must read as "no verdict" so that no action is offered + * on a guess. + */ +export function parseConnectorReadiness(value: unknown): ConnectorReadiness | null { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return null; + } + const record = value as Record; + if ( + !( + isOneOf(READINESS_ACTIONS, record.action) && + isOneOf(READINESS_REASONS, record.reason) && + typeof record.browser_bound === "boolean" && + typeof record.connector_key === "string" && + record.connector_key !== "" + ) + ) { + return null; + } + return { + action: record.action, + browserBound: record.browser_bound, + connectorKey: record.connector_key, + detail: typeof record.detail === "string" && record.detail.trim() !== "" ? record.detail.trim() : null, + reason: record.reason, + }; +} + +export function parseConnectorReadinessResponse(payload: unknown): readonly ConnectorReadiness[] { + if (typeof payload !== "object" || payload === null) { + return []; + } + const record = payload as Record; + if (record.object !== "connector_browser_enrollment_readiness" || !Array.isArray(record.data)) { + return []; + } + return record.data.flatMap((item) => { + const readiness = parseConnectorReadiness(item); + return readiness ? [readiness] : []; + }); +} diff --git a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts new file mode 100644 index 000000000..45d604824 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts @@ -0,0 +1,32 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { ConnectorReadiness } from "./connector-readiness-contract.ts"; + +/** + * Owner-facing text for a readiness verdict that blocks setup, or null when the + * verdict needs no explanation of its own: `connect` needs none, and `install` + * is the package column's own Install action. + */ +export function readinessBlockNote(readiness: ConnectorReadiness | null | undefined): string | null { + if (!readiness) { + return null; + } + const detail = readiness.detail ? ` ${readiness.detail}` : ""; + switch (readiness.reason) { + case "artifact_invalid": + return `The installed connector package failed verification. Reinstall it to repair it.${detail}`; + case "host_requirement_unmet": + return `This server is not set up to run this connector yet.${detail}`; + case "inspection_unavailable": + return `The installed connector could not be checked just now. Retry in a moment.${detail}`; + case "catalog_unavailable": + return "The connector catalog could not be reached, so this connector's availability is unknown. Retry in a moment."; + case "not_published": + return "This connector is not published, and no local source or installed package provides it."; + case "not_browser_bound": + case "not_installed": + case "runnable": + return null; + } +} diff --git a/apps/console/src/app/(console)/lib/load-browser-connect-support.ts b/apps/console/src/app/(console)/lib/load-browser-connect-support.ts index ae15dd33b..45225ea53 100644 --- a/apps/console/src/app/(console)/lib/load-browser-connect-support.ts +++ b/apps/console/src/app/(console)/lib/load-browser-connect-support.ts @@ -1,30 +1,23 @@ // Copyright The PDP-Connect Contributors // SPDX-License-Identifier: Apache-2.0 -import { isBrowserBoundConnector } from "pdpp-reference-implementation/connection-setup-plan"; -import { type BindingSource, type BrowserConnectSupport, browserConnectSupport } from "./browser-connect-support.ts"; -import { listConnectorInstallStatus } from "./connector-install-client.ts"; -import { listConnectorManifests } from "./rs-client.ts"; +import { type BrowserConnectSupport, browserConnectSupportFromReadiness } from "./browser-connect-readiness.ts"; +import { getConnectorReadiness } from "./connector-install-client.ts"; +import { ResourceServerHttpError } from "./owner-token.ts"; /** * Browser-session support for the routes under `/connect/browser-session`. - * Binding sources are installed packages and local manifests. A connector whose - * bundled manifest is browser-bound keeps the routes for repair of an existing - * connection, even when no package or manifest is readable. + * Whether an account can be added is the server's verdict + * (`/v1/owner/connector-install/readiness`), the same one the enrollment route + * enforces. The console computes nothing from bindings, tiers or key lists. If + * the verdict cannot be fetched, no new account can be added. A 404 means the + * server predates the endpoint; that server enforces on its own. */ export async function loadBrowserConnectSupport(connectorId: string): Promise { - const [installed, manifests] = await Promise.all([ - listConnectorInstallStatus().catch(() => []), - listConnectorManifests().catch(() => []), - ]); - const sources: BindingSource[] = [ - ...installed.map((entry) => ({ bindings: entry.bindings, connector_id: entry.connector_id })), - ...manifests.map((manifest) => ({ - bindings: manifest.runtime_requirements?.bindings, - connector_id: manifest.connector_id, - connector_key: manifest.connector_key, - })), - ]; - const support = browserConnectSupport(connectorId, sources); - return { ...support, browserBound: support.browserBound || isBrowserBoundConnector(connectorId) }; + let serverPredatesReadiness = false; + const readiness = await getConnectorReadiness(connectorId).catch((error: unknown) => { + serverPredatesReadiness = error instanceof ResourceServerHttpError && error.status === 404; + return null; + }); + return browserConnectSupportFromReadiness(connectorId, readiness, serverPredatesReadiness); } diff --git a/apps/console/src/app/(console)/lib/picker-readiness-verdict.test.ts b/apps/console/src/app/(console)/lib/picker-readiness-verdict.test.ts new file mode 100644 index 000000000..85613da67 --- /dev/null +++ b/apps/console/src/app/(console)/lib/picker-readiness-verdict.test.ts @@ -0,0 +1,134 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +// The Add Source picker offers a browser Connect action only when the server's +// readiness verdict says `connect`. The server computes that verdict once and +// uses it for the template's setup plan, the owner-mediated action, and the +// enrollment route's refusal, so the picker and the route cannot disagree. The +// console reads the verdict; it never derives it from a key list, a roster, a +// tier or the manifest's bindings. + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + buildOwnerConnectorCatalog, + type CatalogManifestLike, + type ConnectorCatalogEntry, + type OwnerConnectorTemplateLike, +} from "./connection-catalog.ts"; +import { type ConnectorReadiness, parseConnectorReadiness } from "./connector-readiness-contract.ts"; +import { sourceSetupAction, sourceSetupStatus } from "./source-setup-presentation.ts"; + +type Tier = "supported" | "preview" | "development"; + +/** The server's wire verdict for a browser-bound connector. */ +function verdict(connectorKey: string, overrides: Record): Record { + return { browser_bound: true, connector_key: connectorKey, detail: null, host: "ready", ...overrides }; +} + +/** + * A template as the server projects it. When the verdict is `connect` the + * server grants the manual-enrollment disposition and the owner-mediated + * action; for any other verdict it grants neither. + */ +function template(connectorKey: string, tier: Tier, readiness: Record): OwnerConnectorTemplateLike { + const connect = readiness.action === "connect"; + return { + connector_modality: "browser_bound", + connector_readiness: readiness, + connector_key: connectorKey, + display_name: connectorKey, + public_listing: { tier }, + registration_status: "registered", + setup_plan: { + catalog_disposition: connect ? "browser_collector_manual" : "browser_bound_runbook", + deployment_readiness: { blockers: [], guidance: null, state: "ready" }, + enrollment_key: connect ? connectorKey : null, + next_step_kind: connect ? "enroll_browser_collector" : "manual_runbook", + owner_actionable: connect, + proof_gate: null, + runbook_path: null, + setup_modality: "browser_bound", + support_state: "supported", + }, + supported_actions: connect + ? [{ family: "initiate_connection", method: null, status: "owner_mediated", url: null }] + : [], + }; +} + +function pickerEntry(item: OwnerConnectorTemplateLike): ConnectorCatalogEntry { + const entry = buildOwnerConnectorCatalog([], [item]).find((candidate) => candidate.connectorKey === item.connector_key); + assert.ok(entry, `${item.connector_key} is listed`); + return entry; +} + +test("a connect verdict gives a Connect action at any tier, with the tier badge unchanged", () => { + for (const [tier, badge] of [ + ["development", "In development"], + ["preview", "Preview"], + ] as const) { + const entry = pickerEntry(template("acme-shop", tier, verdict("acme-shop", { action: "connect", reason: "runnable" }))); + assert.equal(entry.disposition, "browser_collector_manual"); + assert.equal(entry.readiness?.action, "connect"); + assert.ok(sourceSetupAction(entry), `acme-shop (${tier}) has a Connect action`); + assert.equal(sourceSetupStatus(entry).label, badge); + } +}); + +test("an install verdict gives no Connect action and never reads 'Not built yet'", () => { + // LinkedIn and Goodreads: published and installable, not yet installed. + for (const connectorKey of ["linkedin", "goodreads"]) { + const entry = pickerEntry( + template(connectorKey, "development", verdict(connectorKey, { action: "install", reason: "not_installed" })) + ); + assert.equal(entry.readiness?.action, "install"); + assert.equal(sourceSetupAction(entry), null, "installing comes first; there is nothing to connect yet"); + assert.equal(sourceSetupStatus(entry).label, "In development"); + } +}); + +test("every verdict other than connect gives no Connect action, whatever the key or tier", () => { + const blocked = [ + { action: "configure", reason: "host_requirement_unmet", detail: "No browser surface is configured." }, + { action: "repair", reason: "artifact_invalid", detail: "entrypoint digest mismatch" }, + { action: "retry", reason: "inspection_unavailable", detail: "EACCES" }, + { action: "retry", reason: "catalog_unavailable" }, + { action: "unavailable", reason: "not_published" }, + { action: "unavailable", reason: "not_browser_bound" }, + ]; + for (const key of ["amazon", "anthropic", "acme-shop"]) { + for (const tier of ["supported", "preview", "development"] as const) { + for (const state of blocked) { + const entry = pickerEntry(template(key, tier, verdict(key, state))); + assert.equal(sourceSetupAction(entry), null, `${key}/${tier}/${state.reason}`); + } + } + } +}); + +test("the entry carries the server's reason, so the card can explain a block", () => { + const entry = pickerEntry( + template("acme-shop", "preview", verdict("acme-shop", { action: "configure", reason: "host_requirement_unmet", detail: "No display." })) + ); + const expected: ConnectorReadiness | null = parseConnectorReadiness( + verdict("acme-shop", { action: "configure", reason: "host_requirement_unmet", detail: "No display." }) + ); + assert.deepEqual(entry.readiness, expected); +}); + +test("without the server's templates a browser connector gets no Connect action", () => { + // The manifest-only catalog is the fallback when the server's template list + // cannot be read. It carries no verdict, so it fails closed. + const manifest: CatalogManifestLike = { + capabilities: { public_listing: { tier: "preview" } }, + connector_id: "acme-shop", + display_name: "acme-shop", + runtime_requirements: { bindings: { browser: { required: true } } }, + }; + const entry = buildOwnerConnectorCatalog([manifest], []).find((candidate) => candidate.connectorKey === "acme-shop"); + assert.ok(entry); + assert.equal(entry.readiness, null); + assert.equal(entry.disposition, "browser_bound_runbook"); + assert.equal(sourceSetupAction(entry), null); +}); diff --git a/apps/console/src/app/(console)/lib/picker-shared-connect-rule.test.ts b/apps/console/src/app/(console)/lib/picker-shared-connect-rule.test.ts deleted file mode 100644 index e78e83781..000000000 --- a/apps/console/src/app/(console)/lib/picker-shared-connect-rule.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// Copyright The PDP-Connect Contributors -// SPDX-License-Identifier: Apache-2.0 - -// The Add Source picker offers a browser Connect action by the same rule as the -// browser-session routes and the RI enrollment-shell route: -// browserEnrollmentSupport(...).canAddAccount. Production readiness shows as -// the tier badge; it does not remove the action. - -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { browserEnrollmentSupport } from "pdpp-reference-implementation/connection-setup-plan"; -import { buildOwnerConnectorCatalog, type CatalogManifestLike, type ConnectorCatalogEntry } from "./connection-catalog.ts"; -import { sourceSetupAction, sourceSetupStatus } from "./source-setup-presentation.ts"; - -type Tier = "supported" | "preview" | "development"; - -function browserManifest(connectorId: string, tier: Tier): CatalogManifestLike { - return { - capabilities: { public_listing: { tier } }, - connector_id: connectorId, - display_name: connectorId, - runtime_requirements: { bindings: { browser: { required: true } } }, - }; -} - -function pickerEntry(connectorId: string, tier: Tier): ConnectorCatalogEntry { - const entry = buildOwnerConnectorCatalog([browserManifest(connectorId, tier)], []).find( - (candidate) => candidate.connectorKey === connectorId - ); - assert.ok(entry, `${connectorId} is listed`); - return entry; -} - -test("a catalog-installed browser connector no key list names gets a Connect action and its tier badge", () => { - for (const [tier, badge] of [ - ["development", "In development"], - ["preview", "Preview"], - ] as const) { - const entry = pickerEntry("acme-shop", tier); - assert.equal(entry.disposition, "browser_collector_manual"); - assert.equal(entry.enrollmentKey, "acme-shop"); - assert.ok(sourceSetupAction(entry), `acme-shop (${tier}) has a Connect action`); - assert.equal(sourceSetupStatus(entry).label, badge); - } -}); - -test("a known scaffold gets no Connect action", () => { - const entry = pickerEntry("anthropic", "development"); - assert.equal(entry.disposition, "browser_bound_runbook"); - assert.equal(sourceSetupAction(entry), null); - assert.equal(sourceSetupStatus(entry).label, "Not built yet"); -}); - -test("a production-ready bundled browser connector still gets a Connect action", () => { - const entry = pickerEntry("amazon", "supported"); - assert.equal(entry.disposition, "browser_collector_manual"); - assert.ok(sourceSetupAction(entry)); - assert.equal(entry.publicTier, "supported"); -}); - -test("the picker's Connect decision equals browserEnrollmentSupport for every case", () => { - for (const connectorId of ["acme-shop", "anthropic", "amazon", "doordash", "reddit"]) { - const entry = pickerEntry(connectorId, "preview"); - const bindings = { connector_id: connectorId, runtime_requirements: { bindings: { browser: { required: true } } } }; - assert.equal( - entry.disposition === "browser_collector_manual", - browserEnrollmentSupport(connectorId, bindings).canAddAccount, - connectorId - ); - } -}); diff --git a/apps/console/src/app/(console)/lib/source-add-support.test.ts b/apps/console/src/app/(console)/lib/source-add-support.test.ts index e770a6cdb..660c35ca2 100644 --- a/apps/console/src/app/(console)/lib/source-add-support.test.ts +++ b/apps/console/src/app/(console)/lib/source-add-support.test.ts @@ -21,7 +21,6 @@ import { buildSourceAddSupport, resolveSourceAddSupport } from "./source-add-sup const SUPPORTED_LISTING = { public_listing: { tier: "supported" as const } }; const ADD_ANOTHER_ACCOUNT_LABEL_RE = /add another account/i; -const CONNECT_BROWSER_SESSION_ROUTE_RE = /\/connect\/browser-session\/amazon/; const STATIC_SECRET_ROUTE_RE = /\/connect\/static-secret\/gmail/; const DEVICE_EXPORTER_ROUTE_RE = /\/device-exporters\?connector=/; const MANUAL_UPLOAD_ROUTE_RE = /\/connect\/manual-upload\/google-maps/; @@ -145,19 +144,16 @@ test("supported local collector supports self-service add and routes to enrollme assert.match(support.action.href, DEVICE_EXPORTER_ROUTE_RE); }); -test("supported browser collector source is self-service and routes to browser-session setup", () => { +test("a browser collector manifest alone is not self-service: that verdict comes from the server", () => { const map = buildSourceAddSupport([browserCollectorManifest("amazon")]); const support = resolveSourceAddSupport(map, "amazon"); assert.ok(support); - assert.equal(support.support, "self_service"); - assert.ok(support.action); - assert.match(support.action.label, ADD_ANOTHER_ACCOUNT_LABEL_RE); - assert.match(support.action.href, CONNECT_BROWSER_SESSION_ROUTE_RE); - assert.match(support.supportLabel, ADD_ANOTHER_ACCOUNT_LABEL_RE); - assert.doesNotMatch(support.supportLabel, DEMOTION_COPY_RE); + assert.equal(support.support, "not_self_service"); + assert.equal(support.action, null); + assert.match(support.supportLabel, NOT_SELF_SERVICE_RE); }); -test("a known-scaffold browser runbook stays unavailable for self-service setup", () => { +test("a browser-bound runbook stays unavailable for self-service setup", () => { const map = buildSourceAddSupport([browserBoundManifest("anthropic")]); const support = resolveSourceAddSupport(map, "anthropic"); assert.ok(support); @@ -167,15 +163,6 @@ test("a known-scaffold browser runbook stays unavailable for self-service setup" assert.doesNotMatch(support.supportLabel, DEMOTION_COPY_RE); }); -test("a browser source no key list names gets browser self-service setup", () => { - const map = buildSourceAddSupport([browserBoundManifest("some_browser_source")]); - const support = resolveSourceAddSupport(map, "some_browser_source"); - assert.ok(support); - assert.equal(support.support, "self_service"); - assert.ok(support.action); - assert.match(support.action.href, /\/connect\/browser-session\/some_browser_source/); -}); - test("unshipped manual upload path stays unavailable for self-service setup", () => { const map = buildSourceAddSupport([manualUploadPendingManifest("google-maps-pending")]); const support = resolveSourceAddSupport(map, "google-maps-pending"); diff --git a/apps/console/src/app/(console)/lib/source-setup-card-action.test.ts b/apps/console/src/app/(console)/lib/source-setup-card-action.test.ts index 878534949..2421276e1 100644 --- a/apps/console/src/app/(console)/lib/source-setup-card-action.test.ts +++ b/apps/console/src/app/(console)/lib/source-setup-card-action.test.ts @@ -42,7 +42,7 @@ function entry(overrides: Partial): ConnectorCatalogEntry disposition: "static_secret_connect", externalDocs: [], icon: null, - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "api_network", nextStepKind: "capture_static_secret", diff --git a/apps/console/src/app/(console)/lib/source-setup-presentation.test.ts b/apps/console/src/app/(console)/lib/source-setup-presentation.test.ts index 3afa54ba2..2f10d4190 100644 --- a/apps/console/src/app/(console)/lib/source-setup-presentation.test.ts +++ b/apps/console/src/app/(console)/lib/source-setup-presentation.test.ts @@ -53,7 +53,6 @@ function makeEntry(overrides: Partial & Pick { @@ -214,7 +212,6 @@ test("development + real (non-scaffold) self-testable disposition gets a self-te connectorKey: "imessage", disposition: "local_collector_enroll", displayName: "iMessage", - isKnownScaffold: false, modality: "local_collector", ownerActionable: false, publicTier: "development", @@ -228,47 +225,49 @@ test("development + real (non-scaffold) self-testable disposition gets a self-te assert.equal(sourceSetupStatus(imessage).label, "In development"); }); -test("development + known scaffold gets no action and a distinct 'Not built yet' status", () => { +test("a development entry the server cannot run gets no action and the tier label, not 'Not built yet'", () => { const anthropic = makeEntry({ connectorKey: "anthropic", disposition: "browser_bound_runbook", displayName: "Anthropic", - isKnownScaffold: true, modality: "browser_bound", ownerActionable: false, publicTier: "development", + readiness: { + action: "unavailable", + browserBound: true, + connectorKey: "anthropic", + detail: null, + reason: "not_published", + }, setupModality: "browser_bound", }); assert.equal(isRunnableAddOffer(anthropic), false); - assert.equal(sourceSetupAction(anthropic), null, "a known scaffold must never render an add action"); - assert.equal(sourceSetupStatus(anthropic).label, "Not built yet"); - assert.match( - sourceSetupGuidance(anthropic), - /scaffold/i, - "scaffold guidance must say plainly there is nothing to test yet" - ); + assert.equal(sourceSetupAction(anthropic), null, "a disposition with no safe action renders no action"); + assert.equal(sourceSetupStatus(anthropic).label, "In development"); + assert.doesNotMatch(sourceSetupGuidance(anthropic), /scaffold/i); }); -test("development + known scaffold with a self-testable disposition still gets no action", () => { - // The structural safety property: isKnownScaffold overrides disposition. - // Even if a scaffold's disposition happens to land in the self-test - // allowlist (e.g. a future scaffold resolves to static_secret_connect), - // clicking an action for it can never collect anything. - const scaffoldWithRunnableDisposition = makeEntry({ - connectorKey: "hypothetical-scaffold", +test("a roster-style name never changes what a card offers: only disposition and the verdict do", () => { + // The same key with the same disposition gets the same action whatever the + // verdict says; the server expresses a block through the disposition. + const base = { + connectorKey: "hypothetical-name", disposition: "static_secret_connect", - isKnownScaffold: true, publicTier: "development", setupModality: "static_secret", + } as const; + const withVerdict = makeEntry({ + ...base, + readiness: { action: "install", browserBound: true, connectorKey: "hypothetical-name", detail: null, reason: "not_installed" }, }); - assert.equal(sourceSetupAction(scaffoldWithRunnableDisposition), null); + assert.deepEqual(sourceSetupAction(withVerdict), sourceSetupAction(makeEntry(base))); }); test("development + a disposition with no safe action gets honest fallback guidance, not the generic dead-end", () => { const localCollectorUnproven = makeEntry({ connectorKey: "some-filesystem-connector", disposition: "local_collector_unproven", - isKnownScaffold: false, modality: "local_collector", ownerActionable: false, publicTier: "development", @@ -286,7 +285,6 @@ test("development real entry surfaces its manifest listingNote in guidance when const spotify = makeEntry({ connectorKey: "spotify", disposition: "static_secret_connect", - isKnownScaffold: false, listingNote: "Hidden from the reference dashboard catalog until a credentialed run proves useful records in the deployment.", publicTier: "development", setupModality: "static_secret", @@ -299,27 +297,24 @@ test("development real entry surfaces its manifest listingNote in guidance when * offer an install action" defect. `sourceSetupRowIsUnavailable` is the * predicate the catalog uses to suppress the Package column's install * control and sort a row to the bottom -- it must match exactly "nothing - * this owner can click" (a known scaffold, or any other disposition - * `sourceSetupAction` refuses), never the broader + * this owner can click" (any disposition `sourceSetupAction` refuses), never the broader * `sourceSetupAvailability === "not_available_here"` set, which also covers * a real self-testable Development entry that DOES have a click target. */ -test("a known scaffold has no click target and is unavailable", () => { - const scaffold = makeEntry({ +test("a disposition with no safe action has no click target and is unavailable", () => { + const unrunnable = makeEntry({ connectorKey: "anthropic", disposition: "browser_bound_runbook", - isKnownScaffold: true, publicTier: "development", setupModality: "browser_bound", }); - assert.equal(sourceSetupRowIsUnavailable(scaffold), true); + assert.equal(sourceSetupRowIsUnavailable(unrunnable), true); }); test("a real self-testable development entry has a click target and is NOT unavailable", () => { const imessage = makeEntry({ connectorKey: "imessage", disposition: "local_collector_enroll", - isKnownScaffold: false, modality: "local_collector", ownerActionable: false, publicTier: "development", @@ -332,7 +327,6 @@ test("a supported entry with a real action is not unavailable", () => { const chatgpt = makeEntry({ connectorKey: "chatgpt", disposition: "static_secret_connect", - isKnownScaffold: false, ownerActionable: true, publicTier: "supported", setupModality: "static_secret", @@ -344,7 +338,6 @@ test("an unclassified unsupported disposition has no click target and is unavail const unsupported = makeEntry({ connectorKey: "some-dead-connector", disposition: "unknown_unsupported", - isKnownScaffold: false, ownerActionable: false, publicTier: "supported", setupModality: "provider_authorization", @@ -353,24 +346,22 @@ test("an unclassified unsupported disposition has no click target and is unavail }); test("sourceSetupRank always sorts an unavailable row after every rank a normal row can have", () => { - const scaffold = makeEntry({ + const unrunnable = makeEntry({ connectorKey: "anthropic", disposition: "browser_bound_runbook", - isKnownScaffold: true, publicTier: "development", setupModality: "browser_bound", }); const experimental = makeEntry({ connectorKey: "signal", disposition: "static_secret_experimental", - isKnownScaffold: false, publicTier: "preview", setupModality: "static_secret", supportState: "experimental", }); assert.equal(sourceSetupRowIsUnavailable(experimental), false, "test fixture sanity: experimental opt-in has a click target"); assert.ok( - sourceSetupRank(scaffold) > sourceSetupRank(experimental), + sourceSetupRank(unrunnable) > sourceSetupRank(experimental), "an unavailable row's rank must exceed even the lowest-priority actionable rank" ); }); diff --git a/apps/console/src/app/(console)/lib/source-setup-presentation.ts b/apps/console/src/app/(console)/lib/source-setup-presentation.ts index 1c5da9e4c..a2679ed36 100644 --- a/apps/console/src/app/(console)/lib/source-setup-presentation.ts +++ b/apps/console/src/app/(console)/lib/source-setup-presentation.ts @@ -198,26 +198,13 @@ export function sourceSetupRowIsUnavailable(entry: ConnectorCatalogEntry): boole /** The owner-facing status label + tone for first-account setup. */ export function sourceSetupStatus(entry: ConnectorCatalogEntry): SourceSetupStatus { if (entry.publicTier === "development") { - // "Not built yet" and "In development" are deliberately different labels: - // a scaffold has no collection code at all (there is nothing to test), - // while a real Development entry has an implemented, self-testable setup - // path that simply has not been proven against a live account yet. - // Collapsing these into one badge would hide exactly the distinction the - // owner needs to decide whether clicking a card can do anything. "Not - // built yet" reads as a known roadmap gap (part of the shared "not - // available yet" family), never as a - // broken build. - return entry.isKnownScaffold - ? { - description: "This connector is registered, but nobody has written its collection code yet.", - label: "Not built yet", - tone: "border-border bg-muted/30 text-muted-foreground", - } - : { - description: "This connector has a setup path, but it has not completed live-account validation.", - label: publicTierLabel(entry.publicTier), - tone: "border-border bg-muted/30 text-muted-foreground", - }; + // The tier says how much live-account proof exists. Whether the owner can + // act now is the server's readiness verdict, shown separately. + return { + description: "This connector has a setup path, but it has not completed live-account validation.", + label: publicTierLabel(entry.publicTier), + tone: "border-border bg-muted/30 text-muted-foreground", + }; } if (entry.publicTier === "preview") { return { @@ -409,12 +396,6 @@ function isDevelopmentSelfTestDisposition(disposition: ConnectorCatalogEntry["di * unproven or unimplemented -- so this names that honestly instead. */ function developmentFallbackGuidance(entry: ConnectorCatalogEntry): string { - if (entry.isKnownScaffold) { - const note = entry.listingNote; - return note - ? `This connector is scaffolded, not finished, and cannot collect data yet: ${note}` - : "This connector is scaffolded, not finished: it can reach the provider but does not collect data yet. There is nothing to test here yet."; - } // A deployment-blocked entry needs the exact missing settings named, not a // generic "unproven" note -- that fact is more actionable than anything // else this branch could say, and it holds regardless of tier. @@ -492,14 +473,11 @@ export function sourceSetupGuidance(entry: ConnectorCatalogEntry): string { /** The primary next action for first-account setup, or null when none exists. */ export function sourceSetupAction(entry: ConnectorCatalogEntry): SourceSetupAction | null { if (entry.publicTier === "development") { - // A KNOWN scaffold (unconditional SKIP_RESULT, no real collection) must - // never render an action: clicking it can never collect anything, and an - // add button on a stub is a worse experience than the stub staying - // invisible. A real-but-unproven Development entry falls through to the - // same disposition switch below, so it gets the exact action a Preview - // entry with the same disposition would get -- the only difference is the - // Development disclosure's own copy naming it unproven. - if (entry.isKnownScaffold || !isDevelopmentSelfTestDisposition(entry.disposition)) { + // A Development entry falls through to the same disposition switch below, + // so it gets the exact action a Preview entry with the same disposition + // would get. The server decides the disposition from readiness: a connector + // it cannot run never arrives with a connect disposition. + if (!isDevelopmentSelfTestDisposition(entry.disposition)) { return null; } } else if (!(isOwnerActionableEntry(entry) || isExperimentalEntry(entry) || hasDashboardSetupPath(entry))) { @@ -599,14 +577,12 @@ export function sourceSetupCardModel( const noSetupPath = sourceSetupRowIsUnavailable(entry); const installModel = lifecycle && !noSetupPath ? connectorInstallRowModel(entry, lifecycle) : null; const packageAvailabilityUnknown = catalogTransientlyUnavailable && !lifecycle?.installed && !noSetupPath; - const setupAction = entry.isKnownScaffold - ? null - : sourceSetupCardAction(entry, installModel?.activationState ?? null, packageAvailabilityUnknown); - const packageActionAvailable = !(entry.isKnownScaffold || packageAvailabilityUnknown) && Boolean(installModel?.action); + const setupAction = sourceSetupCardAction(entry, installModel?.activationState ?? null, packageAvailabilityUnknown); + const packageActionAvailable = !packageAvailabilityUnknown && Boolean(installModel?.action); const packageInstalled = installModel?.activationState === "active" || installModel?.activationState === "update_available"; const registrationPending = - !(entry.isKnownScaffold || packageAvailabilityUnknown || setupAction) && + !(packageAvailabilityUnknown || setupAction) && packageInstalled && entry.registrationStatus !== "registered"; return { @@ -616,9 +592,7 @@ export function sourceSetupCardModel( packageNeedsInstall: packageActionAvailable && installModel?.activationState === "not_installed", registrationPending, setupAction, - unavailable: - entry.isKnownScaffold || - !(setupAction || packageActionAvailable || packageAvailabilityUnknown || registrationPending), + unavailable: !(setupAction || packageActionAvailable || packageAvailabilityUnknown || registrationPending), }; } diff --git a/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts b/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts index 4f952ee68..63b71ccb2 100644 --- a/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts +++ b/apps/console/src/app/(console)/sources/add/add-source-demo-data.ts @@ -17,7 +17,7 @@ export function buildAddSourceDemoCatalog(): { displayName: "ChatGPT", disposition: "static_secret_connect", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "api_network", nextStepKind: "capture_static_secret", @@ -45,7 +45,7 @@ export function buildAddSourceDemoCatalog(): { displayName: "Amazon", disposition: "manual_upload_connect", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "api_network", nextStepKind: "provide_import_file", @@ -66,7 +66,7 @@ export function buildAddSourceDemoCatalog(): { disposition: "local_collector_enroll", externalDocs: [], enrollmentKey: "claude_code", - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "local_collector", nextStepKind: "enroll_local_collector", @@ -90,7 +90,7 @@ export function buildAddSourceDemoCatalog(): { displayName: "Calendar Demo", disposition: "provider_auth_deployment_blocked", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "api_network", nextStepKind: "needs_deployment_config", @@ -110,7 +110,7 @@ export function buildAddSourceDemoCatalog(): { displayName: "Browser Archive Demo", disposition: "browser_bound_runbook", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: null, modality: "browser_bound", nextStepKind: "manual_runbook", @@ -133,7 +133,7 @@ export function buildAddSourceDemoCatalog(): { displayName: "Podcast History Demo", disposition: "static_secret_connect", externalDocs: [], - isKnownScaffold: false, + readiness: null, listingNote: "Hidden from the reference dashboard catalog until a credentialed run proves useful records in the deployment.", modality: "api_network", nextStepKind: "capture_static_secret", @@ -147,16 +147,22 @@ export function buildAddSourceDemoCatalog(): { supportState: "supported", }, { - // Illustrates the Development disclosure's scaffold bucket: a - // registered manifest whose collector is an unconditional SKIP_RESULT - // placeholder. Never gets an add action, in the disclosure or anywhere else. + // Illustrates a Development entry whose server verdict blocks setup: the + // manifest is registered, but no package or local source provides it, so + // the card explains why instead of offering an action. acquisitionPaths: [], connectorKey: "receipts_scaffold_demo", deploymentReadiness: { blockers: [], guidance: null, state: "ready" }, displayName: "Receipts Scaffold Demo", disposition: "browser_bound_runbook", externalDocs: [], - isKnownScaffold: true, + readiness: { + action: "unavailable", + browserBound: true, + connectorKey: "receipts_scaffold_demo", + detail: null, + reason: "not_published", + }, listingNote: null, modality: "browser_bound", nextStepKind: "manual_runbook", From 3afc5c6d0366285ab1ddd4412e8a5604a36221c9 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 12:02:56 -0600 Subject: [PATCH 03/17] refactor(reference): drop the roster exports nothing reads CONFORMANCE_ROSTER_LOADED and KNOWN_SCAFFOLD_CONNECTORS had one consumer each, both removed with the roster-based availability rule. Keep PRODUCTION_READY_CONNECTORS, which the supported-collector tier helpers still read. Also remove an unused type from the readiness route test. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../server/polyfill-connectors-runtime.ts | 8 -------- .../test/connector-readiness-routes.test.ts | 1 - 2 files changed, 9 deletions(-) diff --git a/reference-implementation/server/polyfill-connectors-runtime.ts b/reference-implementation/server/polyfill-connectors-runtime.ts index 07716ea61..08ba63971 100644 --- a/reference-implementation/server/polyfill-connectors-runtime.ts +++ b/reference-implementation/server/polyfill-connectors-runtime.ts @@ -233,16 +233,8 @@ export function readPolyfillManifests(): readonly PolyfillManifestEntry[] { return typeof read === "function" ? (read() as readonly PolyfillManifestEntry[]) : []; } -/** - * Whether the conformance roster loaded. Without it the two lists below are - * empty, which means "unknown", not "no connector is production-ready or a - * scaffold". - */ -export const CONFORMANCE_ROSTER_LOADED: boolean = optionalModules.roster !== null; export const PRODUCTION_READY_CONNECTORS: Readonly> = (optionalModules.roster?.PRODUCTION_READY_CONNECTORS as Readonly> | undefined) ?? {}; -export const KNOWN_SCAFFOLD_CONNECTORS: readonly string[] = - (optionalModules.roster?.KNOWN_SCAFFOLD_CONNECTORS as readonly string[] | undefined) ?? []; function cleanString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; diff --git a/reference-implementation/test/connector-readiness-routes.test.ts b/reference-implementation/test/connector-readiness-routes.test.ts index 2ebfee97b..e380b046b 100644 --- a/reference-implementation/test/connector-readiness-routes.test.ts +++ b/reference-implementation/test/connector-readiness-routes.test.ts @@ -23,7 +23,6 @@ import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpe const CATALOG_DIGEST = `sha256:${"a".repeat(64)}`; -type TestServer = Awaited>; function asRecord(value: unknown): Record { assert.ok(value !== null && typeof value === "object" && !Array.isArray(value), "expected a JSON object"); From 21c9b919625f47c929dd67a5fa1b729290f09474 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 12:25:08 -0600 Subject: [PATCH 04/17] test(reference-implementation): give the ChatGPT shell test a runnable artifact The browser-enrollment-shell route now refuses a connector that has no verified selected artifact. This test registered the ChatGPT manifest only, so the route answered 409 not_installed before the draft was classified. Publish the manifest as a developer-local source and set browser host capability, the same fixtures the readiness tests use, so the test again reaches the classification it asserts. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../static-secret-setup-status-route.test.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/reference-implementation/test/static-secret-setup-status-route.test.ts b/reference-implementation/test/static-secret-setup-status-route.test.ts index 45429122b..b5dc7f5e2 100644 --- a/reference-implementation/test/static-secret-setup-status-route.test.ts +++ b/reference-implementation/test/static-secret-setup-status-route.test.ts @@ -9,6 +9,7 @@ import { emitSpineEvent } from "../lib/spine.ts"; import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; import { CREDENTIAL_ENCRYPTION_KEY_ENV } from "../server/stores/credential-encryption.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; // Integration coverage for the owner-session static-secret SETUP-STATUS route — // the durable surface that makes an in-flight static-secret setup visible to the @@ -232,9 +233,14 @@ const VALID_TIMELINE_BODY = JSON.stringify({ ], }); -async function registerConnector(asUrl: string, name: string): Promise { +async function registerConnector( + asUrl: string, + name: string, + fixtures?: ReturnType +): Promise { + const manifest = loadManifest(name) as Record; const resp = await fetch(`${asUrl}/connectors`, { - body: JSON.stringify(loadManifest(name)), + body: JSON.stringify(fixtures ? await fixtures.publish(manifest) : manifest), headers: { "Content-Type": "application/json" }, method: "POST", }); @@ -987,8 +993,13 @@ test("manual/upload setup status shows committed acquisition-batch counts after }); test("ChatGPT browser-enrollment-shell draft is classified browser_session, not static_secret, despite its credential-capture manifest", async () => { - await withServer(async ({ asUrl }) => { - await registerConnector(asUrl, "chatgpt"); + // Registration alone is not code availability: the enrollment route refuses + // a connector with no runnable artifact, so give it a verified one. + const fixtures = createBrowserArtifactFixtures(); + const restoreHost = setBrowserHostCapability(true); + try { + await withServer(async ({ asUrl }) => { + await registerConnector(asUrl, "chatgpt", fixtures); const cookie = await login(asUrl); const created = await createBrowserEnrollmentShell(asUrl, cookie, "chatgpt"); @@ -1038,7 +1049,11 @@ test("ChatGPT browser-enrollment-shell draft is classified browser_session, not assert.doesNotMatch(remediation, NO_BROWSER_CREDENTIAL_REMEDIATION); assert.doesNotMatch(remediation, NO_BROWSER_REENTER_REMEDIATION); assert.match(remediation, SECURE_BROWSER_REMEDIATION); - }); + }); + } finally { + restoreHost(); + fixtures.close(); + } }); test("setup status requires an owner session and 404s an unknown connection", async () => { From 037bbbb3c07c28f200f035092c1cb6f0ae4c4ccf Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 12:59:06 -0600 Subject: [PATCH 05/17] fix(scripts): stop requiring a roster predicate in the staged console The staged-console check looked for a bundled roster predicate (isSupportedBrowserCollectorConnector or isKnownScaffoldConnector) and failed the stage when none answered from the roster. The console no longer bundles either predicate: whether the owner can add a browser connector is now decided by the server's readiness verdict, so the Windows and Linux reference-stack staging steps failed with "no bundled roster predicate answers from the roster". Keep the checks that still protect something the console reads: the roster module is required by a bundled module, it and the two other connector package modules resolve inside the stage, and PRODUCTION_READY_CONNECTORS is non-empty. Drop the predicate probe, its scaffold-only test and the fixture's predicate option. Assisted-by: AI Signed-off-by: Tim Nunamaker --- scripts/check-staged-console-connectors.mjs | 54 +++------------------ scripts/ensure-console-stack.js | 4 +- scripts/ensure-console-stack.test.js | 19 +------- scripts/staged-console-bundle.fixture.mjs | 13 ++--- 4 files changed, 13 insertions(+), 77 deletions(-) diff --git a/scripts/check-staged-console-connectors.mjs b/scripts/check-staged-console-connectors.mjs index ed02402e1..d292bc55e 100644 --- a/scripts/check-staged-console-connectors.mjs +++ b/scripts/check-staged-console-connectors.mjs @@ -5,11 +5,13 @@ // modules it needs, using only files inside the stage. // // The console bundles reference-implementation/server/polyfill-connectors- -// runtime.ts. If that module cannot resolve the conformance roster, every -// browser connector loses "Connect account" and nothing fails loudly: the +// runtime.ts. If that module cannot resolve the conformance roster, the +// production-ready list it exports is empty and nothing fails loudly: the // runtime module treats a missing package as "package absent". A build machine // always has the package in its checkout, so the probe blocks every // resolution that leaves the stage, the same as on an owner's machine. +// Whether the owner can add a browser connector no longer depends on the +// roster; the server decides it from the selected artifact. // // The probe runs in a child process with the launcher's cwd (the directory of // server.js, see writeLauncher in ensure-console-stack.js). It installs every @@ -33,10 +35,6 @@ const REQUIRED_CONSOLE_CONNECTOR_SPECIFIERS = [ `${PACKAGE_PREFIX}static-secret-credential-capture`, `${PACKAGE_PREFIX}credential-probe`, ] -// A production-ready browser connector. isSupportedBrowserCollectorConnector -// must accept it; that is the gate the owner hit ("Adding a new Reddit source -// is not available here"). -const SUPPORTED_BROWSER_CONNECTOR = "reddit" const RESULT_PREFIX = "[check-staged-console-connectors] result " function isInside(root, candidate) { @@ -92,60 +90,27 @@ async function probe(serverDirectory, stageRoot) { const moduleIds = Object.keys(webpackRequire.m).filter(id => String(webpackRequire.m[id]).includes(JSON.stringify(ROSTER_SPECIFIER)) ) - // Webpack mangles export names in a production build and drops exports the - // console does not use, so the roster predicate is found by behaviour: a - // one-argument export that accepts the supported connector and rejects every - // known scaffold (isSupportedBrowserCollectorConnector), or the reverse - // (isKnownScaffoldConnector). isBrowserBoundConnector accepts both kinds, and - // without the roster neither predicate matches. - const candidates = [] + // Evaluating each module records which package subpaths it resolved. for (const id of moduleIds) { - for (const value of Object.values(webpackRequire(id))) { - if (typeof value === "function" && value.length === 1) { - candidates.push(value) - } - } + webpackRequire(id) } let productionReady = [] - let scaffolds = [] if (resolutions[ROSTER_SPECIFIER]) { const roster = await import(resolutions[ROSTER_SPECIFIER]) productionReady = Object.keys(roster.PRODUCTION_READY_CONNECTORS ?? {}) - scaffolds = [...(roster.KNOWN_SCAFFOLD_CONNECTORS ?? [])] - } - const answers = (predicate, connector) => { - try { - return predicate(connector) - } catch { - return undefined - } } - const matches = (predicate, supported, scaffold) => - scaffolds.length > 0 && - answers(predicate, SUPPORTED_BROWSER_CONNECTOR) === supported && - scaffolds.every(key => answers(predicate, key) === scaffold) - const rosterPredicate = candidates.some(predicate => - matches(predicate, true, false) - ) - ? "isSupportedBrowserCollectorConnector" - : candidates.some(predicate => matches(predicate, false, true)) - ? "isKnownScaffoldConnector" - : null return { moduleIds, productionReady, resolutions, - rosterPredicate, } } /** * Throws unless the staged console under `stageRoot` resolves the connector * package modules it needs from inside the stage, reads a non-empty - * PRODUCTION_READY_CONNECTORS, and a bundled roster predicate answers from - * the roster: isSupportedBrowserCollectorConnector accepts "reddit", or - * isKnownScaffoldConnector accepts every known scaffold. + * PRODUCTION_READY_CONNECTORS. */ export function assertStagedConsoleConnectors({ stageRoot, @@ -185,11 +150,6 @@ export function assertStagedConsoleConnectors({ if (result.productionReady.length === 0) { problems.push("PRODUCTION_READY_CONNECTORS is empty") } - if (!result.rosterPredicate) { - problems.push( - `no bundled roster predicate answers from the roster: neither isSupportedBrowserCollectorConnector(${JSON.stringify(SUPPORTED_BROWSER_CONNECTOR)}) nor isKnownScaffoldConnector matches the staged roster` - ) - } if (problems.length > 0) { throw new Error( `${label} cannot load @pdpp/polyfill-connectors: ${problems.join("; ")}` diff --git a/scripts/ensure-console-stack.js b/scripts/ensure-console-stack.js index 0a7ac5449..eff9a486d 100644 --- a/scripts/ensure-console-stack.js +++ b/scripts/ensure-console-stack.js @@ -58,7 +58,7 @@ const SIMPLE_ICONS_PACKAGE = join("node_modules", "simple-icons") // The console bundles reference-implementation/server/polyfill-connectors- // runtime.ts, which requires optional @pdpp/polyfill-connectors subpaths at // run time. The bundle reads members of only these three: the conformance -// roster (which connectors can add an account, which are scaffolds), static- +// roster (the production-ready list), static- // secret capture normalization, and the credential validation mode. All three // are leaf ESM files. The other subpaths stay unstaged and resolve to null, // which the runtime module treats as "package absent". @@ -479,7 +479,7 @@ export function stageConsoleStack({ ) } // The same class of failure for the connector package's code: without the - // conformance roster the console refuses every new browser account. + // conformance roster the production-ready list the console reads is empty. try { assertStagedConsoleConnectors({ stageRoot: targetDirectory, diff --git a/scripts/ensure-console-stack.test.js b/scripts/ensure-console-stack.test.js index 0965c4ee2..895560e3e 100644 --- a/scripts/ensure-console-stack.test.js +++ b/scripts/ensure-console-stack.test.js @@ -195,23 +195,6 @@ describe("ensure console stack", () => { } }) - it("accepts a bundle whose only roster predicate is isKnownScaffoldConnector", () => { - // Webpack drops exports the console does not use, so a build may keep - // either roster predicate. - const root = createConsoleBuildFixture() - try { - writeConsoleServerBundle( - join(root, "apps/console/.next/standalone/apps/console"), - { predicate: "scaffold" } - ) - expect(() => - stageConsoleStack({ build: false, profile: "release", projectRoot: root }) - ).not.toThrow() - } finally { - rmSync(root, { force: true, recursive: true }) - } - }) - it("fails loudly when the staged console resolves the connector package only from the build checkout", () => { // The shipped v0.7.59 bundle: webpack wrote the build machine's path into // createRequire, so the package resolved on the build machine and nowhere @@ -230,7 +213,7 @@ describe("ensure console stack", () => { expect(() => stageConsoleStack({ build: false, profile: "release", projectRoot: root }) ).toThrow( - /connector-conformance-roster does not resolve inside the stage.*no bundled roster predicate answers from the roster/ + /connector-conformance-roster does not resolve inside the stage.*PRODUCTION_READY_CONNECTORS is empty/ ) } finally { rmSync(root, { force: true, recursive: true }) diff --git a/scripts/staged-console-bundle.fixture.mjs b/scripts/staged-console-bundle.fixture.mjs index 6abc01a43..aa0795d41 100644 --- a/scripts/staged-console-bundle.fixture.mjs +++ b/scripts/staged-console-bundle.fixture.mjs @@ -27,13 +27,10 @@ module.exports = webpackRequire * `resolveFrom` is the base the bundled module resolves the package from: null * for the process cwd (the fixed runtime), or an absolute file path (the * build-time `import.meta.url` webpack wrote into the shipped bundle). - * `predicate` is the roster predicate the module exports: "supported" - * (isSupportedBrowserCollectorConnector) or "scaffold" - * (isKnownScaffoldConnector). */ export function writeConsoleServerBundle( runtimeDirectory, - { predicate = "supported", resolveFrom = null } = {} + { resolveFrom = null } = {} ) { const serverDirectory = join(runtimeDirectory, ".next", "server") mkdirSync(join(serverDirectory, "chunks"), { recursive: true }) @@ -60,11 +57,7 @@ exports.modules = { const roster = optional("@pdpp/polyfill-connectors/connector-conformance-roster") optional("@pdpp/polyfill-connectors/static-secret-credential-capture") optional("@pdpp/polyfill-connectors/credential-probe") - const ready = roster?.PRODUCTION_READY_CONNECTORS ?? {} - const scaffolds = roster?.KNOWN_SCAFFOLD_CONNECTORS ?? [] - exports.predicate = ${JSON.stringify(predicate)} === "supported" - ? connector => Object.hasOwn(ready, connector) - : connector => scaffolds.includes(connector) + exports.productionReady = Object.keys(roster?.PRODUCTION_READY_CONNECTORS ?? {}) }, } ` @@ -97,7 +90,7 @@ export function writeConnectorPackageModules(packageDirectory) { ) writeFileSync( join(packageDirectory, "src", "connector-conformance-roster.js"), - 'export const PRODUCTION_READY_CONNECTORS = { reddit: { testFile: "reddit.test.ts" } }\nexport const KNOWN_SCAFFOLD_CONNECTORS = ["anthropic"]\n' + 'export const PRODUCTION_READY_CONNECTORS = { reddit: { testFile: "reddit.test.ts" } }\n' ) writeFileSync( join(packageDirectory, "src", "credential-probe.js"), From cf5f2387b5e9c450b992ee2f24491d428e059990 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 15:17:41 -0600 Subject: [PATCH 06/17] test(console): pin the readiness note by rendered output Render the catalog card with a ready verdict and with a failed-verification verdict and assert what the owner sees: account setup and no note for the ready one, the repair explanation and no setup link for the other. Both cases fail against the card without the readiness note. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index 328047b00..7c0d29ec4 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -207,6 +207,35 @@ for (const disposition of DISPOSITIONS_WITHOUT_SETUP_PATH) { }); } +test("a ready verdict offers account setup with no readiness note; a failed-verification verdict explains the repair", async () => { + const [installed] = parseConnectorInstallStatusResponse(connectorInstallStatusFixture).data; + assert.ok(installed); + assert.ok(browserPackage.catalog); + const lifecycle: ConnectorInstallLifecycle = { + catalog: { ...browserPackage.catalog, digest: installed.digest }, + installed: { ...installed, connector_id: "new-browser" }, + }; + const setupReady = { + ...unregisteredBrowser, + disposition: "browser_collector_manual" as const, + ownerActionable: true, + registrationStatus: "registered" as const, + }; + + const ready = await render(false, { ...setupReady, readiness: verdict("runnable", "connect") }, lifecycle); + assert.match(ready, NEW_BROWSER_SETUP); + assert.doesNotMatch(ready, READINESS_NOTE); + + const needsRepair = await render( + false, + { ...unregisteredBrowser, readiness: verdict("artifact_invalid", "repair", "Digest mismatch.") }, + lifecycle + ); + assert.match(needsRepair, READINESS_NOTE); + assert.match(needsRepair, /failed verification\. Reinstall it to repair it\. Digest mismatch\./); + assert.doesNotMatch(needsRepair, BROWSER_SETUP_LINK); +}); + test("an explicit host restriction is displayed without offering Install", async () => { assert.ok(browserPackage.catalog); const html = await render(false, setupCapable, { From c145446d27e2758b6fd531e5f4bd436521ea77de Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 15:57:25 -0600 Subject: [PATCH 07/17] fix(reference): judge browser enrollment by attended-setup needs and bound the readiness reads The enrollment verdict reused the scheduler's host rule, which requires a browser surface configured through environment variables because a scheduled run has no one at the keyboard. Enrollment is attended: the owner opens the browser themselves. A bare host with no such variables therefore got `configure` and a 409 for a connector that could run. The host rule is now one function with two callers: the scheduler keeps the unattended browser requirement, enrollment checks only required tools and local paths. Resolution is now total. A manifest that cannot be hashed, a malformed requirements block or a throwing probe yields `inspection_failed` for that connector instead of failing the whole template list. The list read reuses host-check results for 30 seconds and bounds the catalog read to 5 seconds; the action-time check in the enrollment route always asks the host. `/readiness` accepts at most 50 `connector_id` values and takes its candidate keys from the install store without hashing each artifact twice. The 409 message and the console copy now use a fixed sentence per reason. `detail` stays on the wire for diagnosis but no longer reaches owner-facing text, so an absolute path or a raw store error cannot leak through it. The wire shape drops the unused `artifact`, `catalog` and `host` fields, and the dead `not_consulted` branch is removed. A test compares the console's copy of the action and reason vocabulary with the server's. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../runtime/scheduler-readiness.ts | 31 ++- .../server/connector-install/index.ts | 15 +- .../server/connector-readiness.ts | 210 ++++++++++++++---- .../server/routes/owner-connector-install.ts | 23 +- .../routes/owner-connector-templates.ts | 8 +- .../routes/ref-browser-enrollment-shell.ts | 6 +- .../browser-enrollment-shell-route.test.ts | 28 ++- .../test/connector-readiness-routes.test.ts | 32 ++- .../test/connector-readiness.test.ts | 108 ++++++++- 9 files changed, 386 insertions(+), 75 deletions(-) diff --git a/reference-implementation/runtime/scheduler-readiness.ts b/reference-implementation/runtime/scheduler-readiness.ts index d91b21742..d5dc7088b 100644 --- a/reference-implementation/runtime/scheduler-readiness.ts +++ b/reference-implementation/runtime/scheduler-readiness.ts @@ -205,12 +205,10 @@ async function checkFirstPartyLocalSourceReadiness(manifest: SchedulerManifest): return `required local source path(s) are missing or unreadable: ${missing.join(", ")}`; } -/** - * Whether this host can run a connector with these declared requirements. - * The scheduler asks before an automatic run; the owner-facing readiness - * projection asks with the same function, so there is one set of host rules. - */ -export async function checkManifestHostReadiness(manifest: SchedulerManifest): Promise { +async function checkHostRequirements( + manifest: SchedulerManifest, + options: { readonly unattendedBrowser: boolean } +): Promise { const requirements = getRuntimeRequirements(manifest); for (const tool of requirements.external_tools || []) { // biome-ignore lint/performance/noAwaitInLoops: Work is intentionally sequential to preserve ordering and state transitions. @@ -219,7 +217,7 @@ export async function checkManifestHostReadiness(manifest: SchedulerManifest): P } } - if (requiredBindingEnabled(manifest, "browser") && !browserSurfaceConfigured()) { + if (options.unattendedBrowser && requiredBindingEnabled(manifest, "browser") && !browserSurfaceConfigured()) { return { ready: false, reason: "required browser runtime is not configured for unattended scheduled runs", @@ -234,6 +232,25 @@ export async function checkManifestHostReadiness(manifest: SchedulerManifest): P return { ready: true }; } +/** + * Whether this host can run a connector unattended, on a schedule: external + * tools, a configured browser surface, and local source paths. The scheduler + * asks this before an automatic run. + */ +export function checkManifestHostReadiness(manifest: SchedulerManifest): Promise { + return checkHostRequirements(manifest, { unattendedBrowser: true }); +} + +/** + * Whether this host can run a connector the owner is watching, as in account + * setup: external tools and local source paths. It does not require the + * unattended browser surface; the launcher opens a local headed browser for an + * attended run outside a container. + */ +export function checkAttendedEnrollmentHostReadiness(manifest: SchedulerManifest): Promise { + return checkHostRequirements(manifest, { unattendedBrowser: false }); +} + export function defaultReadinessChecker(schedule: ConnectorSchedule): Promise { return checkManifestHostReadiness(schedule.manifest); } diff --git a/reference-implementation/server/connector-install/index.ts b/reference-implementation/server/connector-install/index.ts index 764a71186..d1f665e99 100644 --- a/reference-implementation/server/connector-install/index.ts +++ b/reference-implementation/server/connector-install/index.ts @@ -557,11 +557,13 @@ export async function listVerifiedActiveConnectors(store: ConnectorInstallStore) } /** - * The artifact a run would execute for this connector, chosen the way the run - * path chooses it: a selected developer-local source first (an invalid one - * blocks, there is no fallback), then the installed active version. Both are - * re-verified against their recorded bytes. A store that throws is not handled - * here: the caller decides what "could not inspect" means. + * The artifact a run would execute for this connector: a selected + * developer-local source first (an invalid one blocks, there is no fallback), + * then the installed active version, as the run path chooses between those two. + * The run path has a third fallback, the built-in seed connectors; they are not + * browser-bound connectors, so this inspection does not consider them. Both + * kinds are re-verified against their recorded bytes. A store that throws is + * not handled here: the caller decides what "could not inspect" means. */ export type SelectedConnectorArtifact = | { readonly status: "none" } @@ -817,6 +819,8 @@ export interface ConnectorInstallService { install: (connectorId: string, digest: string) => Promise; resolveManifestFromCatalog?: (connectorId: string) => Promise | null>; inspectSelected: (connectorId: string) => Promise; + /** Ids of active installs, read from the store without verifying their bytes. */ + installedConnectorIds?: () => Promise; addLocalSource?: (sourcePath: string) => Promise; listLocalSources?: () => Promise; reloadLocalSource?: (sourceId: string) => Promise; @@ -1210,6 +1214,7 @@ export function createConnectorInstallService(options: { removeLocalSource: (sourceId) => localSourceStore.remove(sourceId), selectLocalSource: (connectorKey, sourceId) => localSourceStore.select(connectorKey, sourceId), inspectSelected: (connectorId) => inspectSelectedConnectorArtifact(store, localSourceStore, connectorId), + installedConnectorIds: async () => (await store.listActive()).map((record) => record.connectorId), status: async () => (await listVerifiedActiveConnectors(store)).verified, uninstall, async update(connectorId) { diff --git a/reference-implementation/server/connector-readiness.ts b/reference-implementation/server/connector-readiness.ts index 6ed59c4f3..8f1c26e12 100644 --- a/reference-implementation/server/connector-readiness.ts +++ b/reference-implementation/server/connector-readiness.ts @@ -9,7 +9,8 @@ * source, else the installed version), re-verified against its recorded * bytes; * - host readiness: the artifact's declared requirements against this host, - * by the same rules the scheduler applies; + * for an attended run (the scheduler's rules minus the unattended browser + * surface, which account setup does not need); * - the signed catalog: discovery only. It decides between "install" and * "not published" when no artifact is selected. It never makes anything * runnable. @@ -18,22 +19,25 @@ * disagree. */ -import { checkManifestHostReadiness } from "../runtime/scheduler-readiness.ts"; +import { checkAttendedEnrollmentHostReadiness } from "../runtime/scheduler-readiness.ts"; import { classifyConnectorIntentModality, type ConnectorManifestLike } from "./connection-setup-plan.ts"; import { canonicalConnectorKey } from "./connector-key.ts"; import type { ConnectorCatalogEntry, ConnectorInstallService, SelectedConnectorArtifact } from "./connector-install/index.ts"; -export type ReadinessAction = "connect" | "install" | "repair" | "configure" | "retry" | "unavailable"; +export const READINESS_ACTIONS = ["connect", "install", "repair", "configure", "retry", "unavailable"] as const; +export type ReadinessAction = (typeof READINESS_ACTIONS)[number]; -export type ReadinessReason = - | "runnable" - | "not_installed" - | "not_published" - | "catalog_unavailable" - | "artifact_invalid" - | "inspection_unavailable" - | "not_browser_bound" - | "host_requirement_unmet"; +export const READINESS_REASONS = [ + "runnable", + "not_installed", + "not_published", + "catalog_unavailable", + "artifact_invalid", + "inspection_unavailable", + "not_browser_bound", + "host_requirement_unmet", +] as const; +export type ReadinessReason = (typeof READINESS_REASONS)[number]; export type CatalogFact = "offered" | "not_offered" | "unavailable" | "not_consulted"; @@ -54,6 +58,28 @@ export interface BrowserEnrollmentReadiness { readonly reason: ReadinessReason; } +/** + * What the owner console reads: the verdict, not the evidence behind it. The + * artifact, catalog and host echoes stay server-side (tests and diagnostics). + */ +export interface BrowserEnrollmentReadinessWire { + readonly action: ReadinessAction; + readonly browser_bound: boolean; + readonly connector_key: string; + readonly detail: string | null; + readonly reason: ReadinessReason; +} + +export function toReadinessWire(readiness: BrowserEnrollmentReadiness): BrowserEnrollmentReadinessWire { + return { + action: readiness.action, + browser_bound: readiness.browser_bound, + connector_key: readiness.connector_key, + detail: readiness.detail, + reason: readiness.reason, + }; +} + export type ArtifactFact = SelectedConnectorArtifact | { readonly status: "inspection_failed"; readonly reason: string }; export interface ReadinessFacts { @@ -68,7 +94,7 @@ export interface ReadinessFacts { readonly hintBrowserBound: boolean; } -function isBrowserBound(manifest: Record | null): boolean { +function isBrowserBound(manifest: object | null): boolean { return classifyConnectorIntentModality(manifest as ConnectorManifestLike | null) === "browser_bound"; } @@ -97,6 +123,9 @@ function artifactSummary(artifact: ArtifactFact): BrowserEnrollmentReadiness["ar * no artifact, catalog unreachable -> retry catalog_unavailable * no artifact, catalog does not offer it -> unavailable not_published * + * With no artifact the catalog is always consulted, so `not_consulted` only + * accompanies a selected artifact. + * * A selected artifact is runnable whatever the catalog says: an older * installed version and an unpublished developer-local source both connect. */ @@ -140,11 +169,45 @@ export function projectBrowserEnrollmentReadiness(facts: ReadinessFacts): Browse if (facts.catalog === "unavailable") { return result("retry", "catalog_unavailable"); } - return result("unavailable", facts.catalog === "not_offered" ? "not_published" : "not_installed"); + return result("unavailable", "not_published"); +} + +/** + * One sentence per reason, safe to show an owner or put in an error. The + * verdict's `detail` (store errors, host paths) is a diagnostic and is never + * part of this text. + */ +export function readinessReasonMessage(reason: ReadinessReason): string { + switch (reason) { + case "not_installed": + return "its package is not installed yet"; + case "not_published": + return "it is not published, and no local source or installed package provides it"; + case "catalog_unavailable": + return "the connector catalog could not be reached"; + case "artifact_invalid": + return "its installed package failed verification"; + case "inspection_unavailable": + return "its installed package could not be checked just now"; + case "not_browser_bound": + return "it does not use browser setup"; + case "host_requirement_unmet": + return "this server is not set up to run it yet"; + case "runnable": + return "it can run"; + } } export interface BrowserEnrollmentReadinessResolver { - resolve: (connectorKey: string) => Promise; + /** + * The verdict for one connector, asking the host afresh (the action-time + * check). `registeredManifest`, when the caller already holds it, replaces the + * resolver's own lookup of the registered manifest. + */ + resolve: ( + connectorKey: string, + registeredManifest?: object | null + ) => Promise; /** One catalog read for the whole list. */ resolveMany: (connectorKeys: readonly string[]) => Promise; } @@ -169,13 +232,34 @@ function catalogFactFor(connectorKey: string, entries: readonly ConnectorCatalog return { browserBound: null, fact: "not_offered" as const }; } return { - browserBound: offered.bindings - ? isBrowserBound({ runtime_requirements: { bindings: offered.bindings } }) - : null, + // A catalog entry that declares no bindings says nothing about browser use. + browserBound: + offered.bindings && Object.keys(offered.bindings).length > 0 + ? isBrowserBound({ runtime_requirements: { bindings: offered.bindings } }) + : null, fact: "offered" as const, }; } +const HOST_CHECK_TTL_MS = 30_000; +const CATALOG_READ_TIMEOUT_MS = 5000; + +/** Resolves to `null` if `work` has not settled within `ms`. */ +async function withTimeout(work: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + work, + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), ms); + timer.unref?.(); + }), + ]); + } finally { + clearTimeout(timer); + } +} + export function createBrowserEnrollmentReadinessResolver(deps: { readonly installService: Pick; /** @@ -184,27 +268,48 @@ export function createBrowserEnrollmentReadinessResolver(deps: { * same answer for the same connector. It never makes anything runnable. */ readonly registeredManifest?: (connectorKey: string) => Promise | null>; - /** Test seam; production uses the scheduler's host rules. */ - readonly checkHost?: typeof checkManifestHostReadiness; + /** Test seams; production uses the attended-enrollment host rules, the wall clock and the defaults above. */ + readonly checkHost?: typeof checkAttendedEnrollmentHostReadiness; + readonly now?: () => number; + readonly catalogTimeoutMs?: number; }): BrowserEnrollmentReadinessResolver { - const checkHost = deps.checkHost ?? checkManifestHostReadiness; + const checkHost = deps.checkHost ?? checkAttendedEnrollmentHostReadiness; + const now = deps.now ?? Date.now; + const catalogTimeoutMs = deps.catalogTimeoutMs ?? CATALOG_READ_TIMEOUT_MS; - const resolveWith = async ( - rawKey: string, - readCatalog: CatalogRead - ): Promise => { - const connectorKey = canonicalConnectorKey(rawKey) ?? rawKey; - let artifact: ArtifactFact; - try { - artifact = await deps.installService.inspectSelected(connectorKey); - } catch (error) { - artifact = { reason: messageOf(error), status: "inspection_failed" }; + // Host detection spawns the manifest's detect commands. A list read reuses a + // result for the same declared requirements for a short time; a single read at action time + // (`resolve`) always asks the host. + const hostResults = new Map }>(); + const hostFor = ( + connectorKey: string, + artifact: Extract, + reuse: boolean + ): Promise<{ ready: boolean; reason?: string }> => { + const key = `${connectorKey}\u0000${JSON.stringify(artifact.manifest.runtime_requirements ?? null)}`; + const cached = hostResults.get(key); + if (reuse && cached && now() - cached.at < HOST_CHECK_TTL_MS) { + return cached.result; } - let host: ReadinessFacts["host"] = null; - if (artifact.status === "active" && isBrowserBound(artifact.manifest)) { - host = await checkHost(artifact.manifest); - } - const hint = await deps.registeredManifest?.(connectorKey).catch(() => null); + const result = checkHost(artifact.manifest); + hostResults.set(key, { at: now(), result }); + result.catch(() => hostResults.delete(key)); + return result; + }; + + const resolveUnchecked = async ( + connectorKey: string, + readCatalog: CatalogRead, + reuseHostResult: boolean, + heldManifest: object | null | undefined + ): Promise => { + const artifact = await deps.installService.inspectSelected(connectorKey); + const host = + artifact.status === "active" && isBrowserBound(artifact.manifest) + ? await hostFor(connectorKey, artifact, reuseHostResult) + : null; + const hint = + heldManifest === undefined ? await deps.registeredManifest?.(connectorKey).catch(() => null) : heldManifest; const catalog = artifact.status === "none" ? catalogFactFor(connectorKey, await readCatalog()) @@ -219,19 +324,46 @@ export function createBrowserEnrollmentReadinessResolver(deps: { }); }; + // Total: one connector's failure (a store error, a malformed manifest, a host + // probe that throws) is that connector's `retry` verdict, never a failed list. + const resolveWith = async ( + rawKey: string, + readCatalog: CatalogRead, + reuseHostResult: boolean, + heldManifest?: object | null + ): Promise => { + const connectorKey = canonicalConnectorKey(rawKey) ?? rawKey; + try { + return await resolveUnchecked(connectorKey, readCatalog, reuseHostResult, heldManifest); + } catch (error) { + return projectBrowserEnrollmentReadiness({ + artifact: { reason: messageOf(error), status: "inspection_failed" }, + catalog: "not_consulted", + catalogBrowserBound: null, + connectorKey, + hintBrowserBound: false, + host: null, + }); + } + }; + const catalogReader = (): CatalogRead => { let pending: Promise | undefined; return () => { - pending ??= deps.installService.catalog().catch(() => null); + pending ??= withTimeout( + deps.installService.catalog().catch(() => null), + catalogTimeoutMs + ); return pending; }; }; return { - resolve: (connectorKey) => resolveWith(connectorKey, catalogReader()), + resolve: (connectorKey, registeredManifest) => + resolveWith(connectorKey, catalogReader(), false, registeredManifest), resolveMany: (connectorKeys) => { const readCatalog = catalogReader(); - return Promise.all(connectorKeys.map((connectorKey) => resolveWith(connectorKey, readCatalog))); + return Promise.all(connectorKeys.map((connectorKey) => resolveWith(connectorKey, readCatalog, true))); }, }; } diff --git a/reference-implementation/server/routes/owner-connector-install.ts b/reference-implementation/server/routes/owner-connector-install.ts index 47a646234..3634fae8a 100644 --- a/reference-implementation/server/routes/owner-connector-install.ts +++ b/reference-implementation/server/routes/owner-connector-install.ts @@ -7,7 +7,7 @@ import type { ConnectorInstallService, } from "../connector-install/index.ts"; import type { LocalConnectorSourceRecord } from "../connector-install/local-source.ts"; -import type { BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; +import { type BrowserEnrollmentReadinessResolver, toReadinessWire } from "../connector-readiness.ts"; import type { MiddlewareHandler, PdppErrorFn, RouteArg } from "./_route-contract.ts"; interface Request { @@ -81,17 +81,22 @@ function queryList(req: Request, key: string): string[] { return items.flatMap((item) => (typeof item === "string" && item.trim() ? [item.trim()] : [])); } +/** Most connectors one readiness request may name; each costs a hash and a host probe. */ +const MAX_READINESS_CONNECTORS = 50; + /** Every connector an owner could be offered browser setup for: published, installed or developer-local. */ async function readinessCandidateKeys(service: ConnectorInstallService): Promise { const [catalog, installed, local] = await Promise.all([ service.catalog().catch(() => []), - service.status().catch(() => []), + (service.installedConnectorIds?.() ?? service.status().then((records) => records.map((r) => r.connectorId))).catch( + () => [] + ), (service.listLocalSources?.() ?? Promise.resolve([])).catch(() => []), ]); return [ ...new Set([ ...catalog.map((entry) => entry.connector_id), - ...installed.map((record) => record.connectorId), + ...installed, ...local.map((record) => record.connectorKey), ]), ].sort(); @@ -275,9 +280,19 @@ export function mountOwnerConnectorInstall( const readinessHandler: Handler = async (req, res) => { try { const requested = queryList(req, "connector_id"); + if (requested.length > MAX_READINESS_CONNECTORS) { + options.pdppError( + res, + 400, + "invalid_request", + `at most ${MAX_READINESS_CONNECTORS} connector_id values per request`, + "connector_id" + ); + return; + } const keys = requested.length > 0 ? requested : await readinessCandidateKeys(options.service); res.json({ - data: await options.readinessResolver.resolveMany(keys), + data: (await options.readinessResolver.resolveMany(keys)).map(toReadinessWire), object: "connector_browser_enrollment_readiness", }); } catch (error) { diff --git a/reference-implementation/server/routes/owner-connector-templates.ts b/reference-implementation/server/routes/owner-connector-templates.ts index 9b018c3eb..39d4dc9b9 100644 --- a/reference-implementation/server/routes/owner-connector-templates.ts +++ b/reference-implementation/server/routes/owner-connector-templates.ts @@ -17,7 +17,11 @@ import { classifyConnectorIntentModality, staticSecretCredentialCaptureFromManifest, } from "../connection-setup-plan.ts"; -import type { BrowserEnrollmentReadiness, BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; +import { + type BrowserEnrollmentReadiness, + type BrowserEnrollmentReadinessResolver, + toReadinessWire, +} from "../connector-readiness.ts"; import type { OwnerAgentControlAction } from "../metadata.ts"; import type { MiddlewareHandler, RouteArg } from "./_route-contract.ts"; @@ -370,7 +374,7 @@ function projectTemplate( connector_modality: modality, // The server-owned browser-setup verdict (connector-readiness.ts). Null for // a template that is not browser-bound. - connector_readiness: readiness, + connector_readiness: readiness ? toReadinessWire(readiness) : null, display_name: displayNameForTemplate(connectorKey, manifest), icon: manifest.icon ?? null, object: "owner_connector_template", diff --git a/reference-implementation/server/routes/ref-browser-enrollment-shell.ts b/reference-implementation/server/routes/ref-browser-enrollment-shell.ts index 8237d4d62..4cbf7285c 100644 --- a/reference-implementation/server/routes/ref-browser-enrollment-shell.ts +++ b/reference-implementation/server/routes/ref-browser-enrollment-shell.ts @@ -39,7 +39,7 @@ import { randomBytes } from "node:crypto"; import { type ConnectorManifestLike, displayNameForConnector } from "../connection-setup-plan.ts"; -import type { BrowserEnrollmentReadinessResolver } from "../connector-readiness.ts"; +import { type BrowserEnrollmentReadinessResolver, readinessReasonMessage } from "../connector-readiness.ts"; import type { CredentialStateChange } from "../stores/connector-instance-credential-store.ts"; import type { MiddlewareHandler, PdppErrorFn, RouteArg } from "./_route-contract.ts"; @@ -275,7 +275,7 @@ export function mountRefBrowserEnrollmentShell(app: AppLike, ctx: MountRefBrowse // Reject unknown connector before doing anything else (404). const manifest = await ctx.resolveRegisteredConnectorManifest(connectorId); - const readiness = await ctx.readinessResolver.resolve(connectorId); + const readiness = await ctx.readinessResolver.resolve(connectorId, manifest); if (!readiness.browser_bound) { await emitShellAudit(ctx, req, res, { connectorId, @@ -304,7 +304,7 @@ export function mountRefBrowserEnrollmentShell(app: AppLike, ctx: MountRefBrowse res, 409, "connector_browser_setup_unavailable", - `Connector '${connectorId}' cannot add an account through browser setup now: ${readiness.reason}${readiness.detail ? ` (${readiness.detail})` : ""}. Next action: ${readiness.action}.` + `Connector '${connectorId}' cannot add an account through browser setup now (${readiness.reason}): ${readinessReasonMessage(readiness.reason)}. Next action: ${readiness.action}.` ); return; } diff --git a/reference-implementation/test/browser-enrollment-shell-route.test.ts b/reference-implementation/test/browser-enrollment-shell-route.test.ts index 76eb1c2c5..082d807bc 100644 --- a/reference-implementation/test/browser-enrollment-shell-route.test.ts +++ b/reference-implementation/test/browser-enrollment-shell-route.test.ts @@ -404,10 +404,31 @@ test("browser-enrollment shell: refuses when the selected artifact no longer ver }); }); -test("browser-enrollment shell: refuses when this host has no browser surface (409)", async () => { +test("browser-enrollment shell: refuses when this host lacks a tool the connector declares (409)", async () => { await withServer(async ({ asUrl }) => { - await registerConnector(asUrl, "chase"); - setBrowserHostCapability(false); + const manifest = loadManifest("chase") as Record; + const requirements = (manifest.runtime_requirements ?? {}) as Record; + const withTool = { + ...manifest, + runtime_requirements: { + ...requirements, + external_tools: [ + { + detect: { args: ["--version"], executable: "pdpp-tool-that-is-not-installed" }, + license: "test-only", + purpose: "fixture", + name: "fixture-tool", + }, + ], + }, + }; + const registered = await fixtures?.publish(withTool); + const resp = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(registered), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(resp.status, 201, await resp.text()); const cookie = await ownerLogin(asUrl); const res = await fetch(`${asUrl}/_ref/connectors/chase/browser-enrollment-shell`, { headers: { cookie }, @@ -418,6 +439,7 @@ test("browser-enrollment shell: refuses when this host has no browser surface (4 assert.equal(error.code, "connector_browser_setup_unavailable"); assert.match(asString(error.message), /host_requirement_unmet/); assert.match(asString(error.message), /Next action: configure/); + assert.doesNotMatch(asString(error.message), /fixture-tool|pdpp-tool-that-is-not-installed/, "detail is a diagnostic"); }); }); diff --git a/reference-implementation/test/connector-readiness-routes.test.ts b/reference-implementation/test/connector-readiness-routes.test.ts index e380b046b..f5d58d63b 100644 --- a/reference-implementation/test/connector-readiness-routes.test.ts +++ b/reference-implementation/test/connector-readiness-routes.test.ts @@ -219,15 +219,39 @@ test("with no named connector the endpoint lists every published, installed or d }); }); -test("a host with no browser surface: every enrollment is refused with configure", async () => { +test("a bare host with no browser variables still enrolls: attended setup needs no scheduler browser surface", async () => { await withStates({ browserHost: false }, async ({ asUrl, fixtures, rsUrl, token }) => { await register(asUrl, (await fixtures.publish(browserManifest("acme-connect"))) as Record); const readiness = await readinessEndpoint(rsUrl, token, "acme-connect"); - assert.equal(readiness.action, "configure"); + assert.equal(readiness.action, "connect"); + assert.equal(readiness.reason, "runnable"); assert.deepEqual(await templateReadiness(rsUrl, token, "acme-connect"), readiness); const shell = await createShell(asUrl, "acme-connect"); - assert.equal(shell.status, 409); - assert.match(String(asRecord(shell.body.error).message), /host_requirement_unmet/); + assert.equal(shell.status, 201); + }); +}); + +test("the wire verdict is the five fields the console reads, and the refusal message never carries the diagnostic detail", async () => { + await withStates({ browserHost: true }, async ({ asUrl, fixtures, rsUrl, token }) => { + await register(asUrl, (await fixtures.publish(browserManifest("acme-broken"))) as Record); + fixtures.corrupt("acme-broken"); + const verdict = await readinessEndpoint(rsUrl, token, "acme-broken"); + assert.deepEqual(Object.keys(verdict).sort(), ["action", "browser_bound", "connector_key", "detail", "reason"]); + assert.ok(verdict.detail, "the diagnostic detail is on the verdict"); + const shell = await createShell(asUrl, "acme-broken"); + const message = String(asRecord(shell.body.error).message); + assert.match(message, /failed verification/); + assert.ok(!message.includes(String(verdict.detail)), "detail stays a diagnostic"); + }); +}); + +test("one readiness request may name at most 50 connectors", async () => { + await withStates({ browserHost: true }, async ({ rsUrl, token }) => { + const ids = Array.from({ length: 51 }, (_, index) => `connector_id=acme-${index}`).join("&"); + const { status } = await fetchJson(`${rsUrl}/v1/owner/connector-install/readiness?${ids}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + assert.equal(status, 400); }); }); diff --git a/reference-implementation/test/connector-readiness.test.ts b/reference-implementation/test/connector-readiness.test.ts index 7f69def85..feb1dc1aa 100644 --- a/reference-implementation/test/connector-readiness.test.ts +++ b/reference-implementation/test/connector-readiness.test.ts @@ -26,6 +26,8 @@ import { type BrowserEnrollmentReadiness, createBrowserEnrollmentReadinessResolver, projectBrowserEnrollmentReadiness, + READINESS_ACTIONS, + READINESS_REASONS, } from "../server/connector-readiness.ts"; import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; @@ -240,21 +242,98 @@ test("an installed artifact whose bytes changed: repair, never connect", async ( }); }); -test("an installed artifact on a host with no browser surface: configure, with the host's reason", async () => { +test("an installed artifact on a bare host with no browser variables: connect, attended setup needs no scheduler surface", async () => { await withHarness( async (h) => { h.setCatalog([catalogEntry("acme-shop", DIGEST_V1, "1.0.0")]); await h.install("acme-shop", DIGEST_V1); const readiness = await h.resolve("acme-shop"); - assert.equal(readiness.action, "configure"); - assert.equal(readiness.reason, "host_requirement_unmet"); - assert.equal(readiness.host, "blocked"); - assert.match(String(readiness.detail), /browser runtime is not configured/); + assert.equal(readiness.action, "connect"); + assert.equal(readiness.host, "ready"); }, { browserHost: false } ); }); +// ─── Host result: blocked, throwing, reused ───────────────────────────────── + +function activeArtifact(runtimeRequirements: unknown = { bindings: BROWSER_BINDINGS }) { + return { + digest: DIGEST_V1, + manifest: { connector_id: "acme-shop", runtime_requirements: runtimeRequirements }, + source: "installed" as const, + status: "active" as const, + version: "1.0.0", + }; +} + +test("a host that cannot run the connector: configure, with the host's reason as detail only", async () => { + const resolver = createBrowserEnrollmentReadinessResolver({ + checkHost: async () => ({ ready: false, reason: "required tool 'ffmpeg' was not found" }), + installService: { catalog: async () => [], inspectSelected: async () => activeArtifact() }, + }); + const readiness = await resolver.resolve("acme-shop"); + assert.equal(readiness.action, "configure"); + assert.equal(readiness.reason, "host_requirement_unmet"); + assert.equal(readiness.host, "blocked"); + assert.equal(readiness.detail, "required tool 'ffmpeg' was not found"); +}); + +test("one connector's malformed requirements or throwing host probe is that connector's retry, never a failed list", async () => { + const service = { + catalog: async () => [], + inspectSelected: async (key: string) => + key === "acme-bad" ? activeArtifact({ bindings: BROWSER_BINDINGS, external_tools: 5 }) : activeArtifact(), + }; + // The real host rules: a non-iterable `external_tools` throws inside them. + const resolver = createBrowserEnrollmentReadinessResolver({ installService: service }); + const [bad, good] = await resolver.resolveMany(["acme-bad", "acme-shop"]); + assert.ok(bad); + assert.ok(good); + assert.equal(bad.action, "retry"); + assert.equal(bad.reason, "inspection_unavailable"); + assert.equal(good.action, "connect"); + + const throwing = createBrowserEnrollmentReadinessResolver({ + checkHost: async () => { + throw new Error("spawn EAGAIN"); + }, + installService: service, + }); + assert.equal((await throwing.resolve("acme-shop")).reason, "inspection_unavailable"); +}); + +test("a list reuses a host result for a short time; a single read asks the host afresh", async () => { + let clock = 0; + let probes = 0; + const resolver = createBrowserEnrollmentReadinessResolver({ + checkHost: async () => { + probes += 1; + return { ready: true }; + }, + installService: { catalog: async () => [], inspectSelected: async () => activeArtifact() }, + now: () => clock, + }); + await resolver.resolveMany(["acme-shop"]); + await resolver.resolveMany(["acme-shop"]); + assert.equal(probes, 1, "the second list read reuses the result"); + await resolver.resolve("acme-shop"); + assert.equal(probes, 2, "the action-time read always asks the host"); + clock += 31_000; + await resolver.resolveMany(["acme-shop"]); + assert.equal(probes, 3, "an expired result is asked for again"); +}); + +test("a catalog that never answers is retry, within the bound", async () => { + const resolver = createBrowserEnrollmentReadinessResolver({ + catalogTimeoutMs: 20, + installService: { catalog: () => new Promise(() => undefined), inspectSelected: async () => ({ status: "none" as const }) }, + }); + const readiness = await resolver.resolve("acme-shop"); + assert.equal(readiness.action, "retry"); + assert.equal(readiness.reason, "catalog_unavailable"); +}); + test("an installed artifact that does not use a browser: unavailable for browser setup", async () => { await withHarness(async (h) => { h.setCatalog([catalogEntry("acme-api", DIGEST_V1, "1.0.0", NETWORK_BINDINGS)]); @@ -317,6 +396,8 @@ test("a corrupted artifact is still reported browser-bound from the registered m const without = createBrowserEnrollmentReadinessResolver({ installService: service }); assert.equal((await withManifest.resolve("acme-shop")).browser_bound, true); assert.equal((await without.resolve("acme-shop")).browser_bound, false); + // A manifest the caller already holds replaces the hook's lookup. + assert.equal((await without.resolve("acme-shop", browserManifest("acme-shop"))).browser_bound, true); // resolveMany answers exactly what resolve answers. assert.deepEqual(await withManifest.resolveMany(["acme-shop"]), [await withManifest.resolve("acme-shop")]); }); @@ -351,16 +432,27 @@ test("projection rows are evaluated top to bottom: an invalid artifact beats the assert.equal(projected.host, "not_evaluated"); }); -test("a registered manifest alone is not an artifact: with no catalog read it is not installed", () => { +test("a registered manifest alone is not an artifact: it names the connector browser-bound but is never runnable", () => { const projected = projectBrowserEnrollmentReadiness({ artifact: { status: "none" }, - catalog: "not_consulted", + catalog: "not_offered", catalogBrowserBound: null, connectorKey: "acme-shop", hintBrowserBound: true, host: null, }); assert.equal(projected.action, "unavailable"); - assert.equal(projected.reason, "not_installed"); + assert.equal(projected.reason, "not_published"); assert.equal(projected.browser_bound, true); }); + +test("the console's readiness vocabulary matches the server's", async () => { + // The console cannot import the server module (it pulls in the install + // service), so it keeps its own copy of the vocabulary. This test is the + // single check that the two copies agree. + const consoleContract = await import( + "../../apps/console/src/app/(console)/lib/connector-readiness-contract.ts" + ); + assert.deepEqual([...consoleContract.READINESS_ACTIONS], [...READINESS_ACTIONS]); + assert.deepEqual([...consoleContract.READINESS_REASONS], [...READINESS_REASONS]); +}); From 2acf5777227a246e2d093f8533d429e2ade8d2ba Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Thu, 5 Nov 2026 15:57:30 -0600 Subject: [PATCH 08/17] fix(console): trust the server verdict and show retry when it cannot be read The console OR-ed the server's `browserBound` with the bundled manifest, so a connector the server called not browser-bound still opened the repair routes, and any failed fetch of the verdict read as "not found". The verdict now decides both fields. The bundled manifest is consulted only when the server answers 404, meaning it predates the readiness endpoint; that branch is labeled as a stopgap with its removal condition. Any other fetch failure is rethrown so the page shows the error state with its retry action. The catalog card shows a fixed sentence per reason and no longer appends the server's `detail`. The note is computed once per card. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 6 ++- .../components/source-setup-catalog.tsx | 5 +- .../lib/browser-connect-readiness.ts | 18 ++++--- .../lib/connector-readiness-contract.test.ts | 23 ++++++++- .../(console)/lib/connector-readiness-copy.ts | 10 ++-- .../lib/load-browser-connect-support.test.ts | 51 +++++++++++++++++++ .../lib/load-browser-connect-support.ts | 7 ++- 7 files changed, 99 insertions(+), 21 deletions(-) create mode 100644 apps/console/src/app/(console)/lib/load-browser-connect-support.test.ts diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index 7c0d29ec4..4ea534878 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -171,7 +171,8 @@ test("a verdict that blocks setup shows its reason and offers no account setup", { catalog: { ...browserPackage.catalog, digest: installed.digest }, installed: { ...installed, connector_id: "new-browser" } } ); assert.match(html, READINESS_NOTE); - assert.match(html, /No browser surface is configured\./); + assert.match(html, /This server is not set up to run this connector yet\./); + assert.doesNotMatch(html, /No browser surface is configured/, "the server's diagnostic detail is not shown"); assert.doesNotMatch(html, BROWSER_SETUP_LINK); assert.doesNotMatch(html, NOT_BUILT_YET); }); @@ -232,7 +233,8 @@ test("a ready verdict offers account setup with no readiness note; a failed-veri lifecycle ); assert.match(needsRepair, READINESS_NOTE); - assert.match(needsRepair, /failed verification\. Reinstall it to repair it\. Digest mismatch\./); + assert.match(needsRepair, /failed verification\. Reinstall it to repair it\./); + assert.doesNotMatch(needsRepair, /Digest mismatch/, "the server's diagnostic detail is not shown"); assert.doesNotMatch(needsRepair, BROWSER_SETUP_LINK); }); diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.tsx b/apps/console/src/app/(console)/components/source-setup-catalog.tsx index 3d2743e17..c751780d5 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.tsx +++ b/apps/console/src/app/(console)/components/source-setup-catalog.tsx @@ -464,6 +464,7 @@ function SourceSetupCard({ installLifecycle: ConnectorInstallLifecycle | null; }) { const status = sourceSetupStatus(entry); + const readinessNote = readinessBlockNote(entry.readiness); const { installModel, noSetupPath, @@ -507,9 +508,9 @@ function SourceSetupCard({

Package

- {readinessBlockNote(entry.readiness) ? ( + {readinessNote ? ( - {readinessBlockNote(entry.readiness)} + {readinessNote} ) : null} {noSetupPath ? ( diff --git a/apps/console/src/app/(console)/lib/browser-connect-readiness.ts b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts index f902a2ed1..13d850ac3 100644 --- a/apps/console/src/app/(console)/lib/browser-connect-readiness.ts +++ b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts @@ -12,11 +12,15 @@ export interface BrowserConnectSupport { } /** - * Pure mapping from the server's verdict. A missing verdict permits no new - * account, except from a server that predates the readiness endpoint (an older - * server next to a newer console): that server applies its own rule when the - * enrollment route is called, so the console defers to it instead of blocking - * every browser connector. + * Pure mapping from the server's verdict. The verdict decides both fields. + * + * Stopgap for mixed versions only: a server that predates the readiness + * endpoint (an older server next to a newer console) returns no verdict. That + * server applies its own rule when the enrollment route is called, so the + * console defers to it, and uses the bundled manifest to keep the routes + * reachable for connectors it marks browser-bound. Remove this branch, and the + * `isBrowserBoundConnector` import, once no supported server predates the + * endpoint. */ export function browserConnectSupportFromReadiness( connectorId: string, @@ -24,9 +28,7 @@ export function browserConnectSupportFromReadiness( serverPredatesReadiness = false ): BrowserConnectSupport { return { - // A connector the bundled manifest marks browser-bound keeps the routes for - // repair of an existing connection, even when the verdict is unavailable. - browserBound: readiness?.browserBound === true || isBrowserBoundConnector(connectorId), + browserBound: readiness ? readiness.browserBound : serverPredatesReadiness && isBrowserBoundConnector(connectorId), canAddAccount: readiness ? readiness.action === "connect" : serverPredatesReadiness, }; } diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts index 4d482aab7..22fcd14f4 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -102,8 +102,27 @@ test("the routes stay reachable for a browser-bound connector so an existing con ); }); -test("blocking reasons have owner copy that carries the server's detail; others have none", () => { - assert.match(readinessBlockNote(readiness("configure", "host_requirement_unmet", "No display."))!, /not set up to run this connector yet\. No display\./); +test("the verdict alone decides browserBound; the bundled manifest is consulted only for a server that predates the endpoint", () => { + // `linkedin` is browser-bound in the bundled manifest. + assert.equal( + browserConnectSupportFromReadiness("linkedin", { ...readiness("unavailable", "not_browser_bound"), browserBound: false }) + .browserBound, + false, + "the server's verdict is not overridden by the bundled manifest" + ); + assert.equal( + browserConnectSupportFromReadiness("linkedin", null, false).browserBound, + false, + "no verdict and no 404 gives no guess from the bundled manifest" + ); + assert.equal(browserConnectSupportFromReadiness("linkedin", null, true).browserBound, true); + assert.equal(browserConnectSupportFromReadiness("some-new-connector", null, true).browserBound, false); +}); + +test("blocking reasons have fixed owner copy that never carries the server's detail; others have none", () => { + const configure = readinessBlockNote(readiness("configure", "host_requirement_unmet", "No display at /srv/run."))!; + assert.match(configure, /not set up to run this connector yet\./); + assert.doesNotMatch(configure, /No display|\/srv/); assert.match(readinessBlockNote(readiness("repair", "artifact_invalid"))!, /failed verification/); assert.match(readinessBlockNote(readiness("retry", "inspection_unavailable"))!, /Retry/); assert.match(readinessBlockNote(readiness("retry", "catalog_unavailable"))!, /catalog could not be reached/); diff --git a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts index 45d604824..5f93b162e 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts @@ -6,20 +6,20 @@ import type { ConnectorReadiness } from "./connector-readiness-contract.ts"; /** * Owner-facing text for a readiness verdict that blocks setup, or null when the * verdict needs no explanation of its own: `connect` needs none, and `install` - * is the package column's own Install action. + * is the package column's own Install action. The text is fixed per reason: the + * server's `detail` is diagnostic and is never shown to the owner. */ export function readinessBlockNote(readiness: ConnectorReadiness | null | undefined): string | null { if (!readiness) { return null; } - const detail = readiness.detail ? ` ${readiness.detail}` : ""; switch (readiness.reason) { case "artifact_invalid": - return `The installed connector package failed verification. Reinstall it to repair it.${detail}`; + return "The installed connector package failed verification. Reinstall it to repair it."; case "host_requirement_unmet": - return `This server is not set up to run this connector yet.${detail}`; + return "This server is not set up to run this connector yet."; case "inspection_unavailable": - return `The installed connector could not be checked just now. Retry in a moment.${detail}`; + return "The installed connector could not be checked just now. Retry in a moment."; case "catalog_unavailable": return "The connector catalog could not be reached, so this connector's availability is unknown. Retry in a moment."; case "not_published": diff --git a/apps/console/src/app/(console)/lib/load-browser-connect-support.test.ts b/apps/console/src/app/(console)/lib/load-browser-connect-support.test.ts new file mode 100644 index 000000000..535f95a54 --- /dev/null +++ b/apps/console/src/app/(console)/lib/load-browser-connect-support.test.ts @@ -0,0 +1,51 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mock, test } from "node:test"; + +// The loader reads the verdict through connector-install-client; replace only +// that read so each test chooses what the server answered. +let answer: () => Promise = async () => null; + +let loaded: Promise<{ + loadBrowserConnectSupport: typeof import("./load-browser-connect-support.ts").loadBrowserConnectSupport; + ResourceServerHttpError: typeof import("./owner-token.ts").ResourceServerHttpError; +}> | null = null; + +function load() { + loaded ??= (async () => { + mock.module("server-only", { namedExports: {} }); + mock.module(new URL("./connector-install-client.ts", import.meta.url).href, { + namedExports: { getConnectorReadiness: () => answer() }, + }); + return { + ...(await import("./load-browser-connect-support.ts")), + ...(await import("./owner-token.ts")), + }; + })(); + return loaded; +} + +test("a 404 from the readiness endpoint means the server predates it, so the console defers", async () => { + const { loadBrowserConnectSupport, ResourceServerHttpError } = await load(); + answer = async () => { + throw new ResourceServerHttpError("/v1/owner/connector-install/readiness", 404, "not found"); + }; + const support = await loadBrowserConnectSupport("linkedin"); + assert.deepEqual(support, { browserBound: true, canAddAccount: true }); +}); + +test("any other failure is rethrown, so the page shows its retry state instead of not-found", async () => { + const { loadBrowserConnectSupport, ResourceServerHttpError } = await load(); + for (const status of [500, 502, 503]) { + answer = async () => { + throw new ResourceServerHttpError("/v1/owner/connector-install/readiness", status, "boom"); + }; + await assert.rejects(loadBrowserConnectSupport("linkedin"), ResourceServerHttpError, String(status)); + } + answer = async () => { + throw new Error("network down"); + }; + await assert.rejects(loadBrowserConnectSupport("linkedin"), /network down/); +}); diff --git a/apps/console/src/app/(console)/lib/load-browser-connect-support.ts b/apps/console/src/app/(console)/lib/load-browser-connect-support.ts index 45225ea53..c7eb8db43 100644 --- a/apps/console/src/app/(console)/lib/load-browser-connect-support.ts +++ b/apps/console/src/app/(console)/lib/load-browser-connect-support.ts @@ -16,8 +16,11 @@ import { ResourceServerHttpError } from "./owner-token.ts"; export async function loadBrowserConnectSupport(connectorId: string): Promise { let serverPredatesReadiness = false; const readiness = await getConnectorReadiness(connectorId).catch((error: unknown) => { - serverPredatesReadiness = error instanceof ResourceServerHttpError && error.status === 404; - return null; + if (error instanceof ResourceServerHttpError && error.status === 404) { + serverPredatesReadiness = true; + return null; + } + throw error; }); return browserConnectSupportFromReadiness(connectorId, readiness, serverPredatesReadiness); } From ea931195440d8788212f4be6101877e668eee9f5 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 00:08:28 -0600 Subject: [PATCH 09/17] fix(reference): withhold browser enrollment from a package that declares it does not collect Removing the conformance roster also removed the only thing that kept a placeholder connector out of enrollment. A placeholder's package verifies, its host requirements are met, and its collection step only reports a skip, so the readiness verdict said `connect`: the owner could create an enrollment shell, sign in, and collect nothing, repeatedly. The package now declares this about itself: `capabilities.collection.implemented: false` in its manifest. A verified, browser-bound package with that declaration reads `unavailable` with the new reason `not_implemented`, before the host is asked, so the enrollment route answers 409 and the catalog card shows a fixed placeholder note. The server holds no connector list for this, and an absent or other declaration changes nothing. Manifest validation already accepts the key, and an older server ignores it. Until a placeholder package publishes the declaration, the server cannot tell it from a working one; that is a change in the connector package, not here. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../server/connector-readiness.ts | 23 +++++++++- .../browser-enrollment-shell-route.test.ts | 27 ++++++++++++ .../test/connector-readiness.test.ts | 42 +++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/reference-implementation/server/connector-readiness.ts b/reference-implementation/server/connector-readiness.ts index 8f1c26e12..c15682c7f 100644 --- a/reference-implementation/server/connector-readiness.ts +++ b/reference-implementation/server/connector-readiness.ts @@ -35,6 +35,7 @@ export const READINESS_REASONS = [ "artifact_invalid", "inspection_unavailable", "not_browser_bound", + "not_implemented", "host_requirement_unmet", ] as const; export type ReadinessReason = (typeof READINESS_REASONS)[number]; @@ -98,6 +99,20 @@ function isBrowserBound(manifest: object | null): boolean { return classifyConnectorIntentModality(manifest as ConnectorManifestLike | null) === "browser_bound"; } +/** + * Whether the package's own manifest says it collects nothing yet. A placeholder + * package verifies and runs, but its collection step only reports a skip, so + * offering it would send the owner through sign-in for no data. The package + * declares this about itself (`capabilities.collection.implemented: false`); + * the server holds no list of such connectors, and an absent declaration means + * the package collects. + */ +function declaresNoCollection(manifest: object | null): boolean { + const capabilities = (manifest as { capabilities?: unknown } | null)?.capabilities; + const collection = (capabilities as { collection?: unknown } | null | undefined)?.collection; + return (collection as { implemented?: unknown } | null | undefined)?.implemented === false; +} + function artifactSummary(artifact: ArtifactFact): BrowserEnrollmentReadiness["artifact"] { switch (artifact.status) { case "active": @@ -117,6 +132,7 @@ function artifactSummary(artifact: ArtifactFact): BrowserEnrollmentReadiness["ar * artifact inspection threw -> retry inspection_unavailable * artifact bytes fail verification -> repair artifact_invalid * artifact verified, manifest not browser-bound -> unavailable not_browser_bound + * artifact verified, declares no collection -> unavailable not_implemented * artifact verified, host requirement unmet -> configure host_requirement_unmet * artifact verified, host ready -> connect runnable * no artifact, catalog offers it -> install not_installed @@ -158,6 +174,9 @@ export function projectBrowserEnrollmentReadiness(facts: ReadinessFacts): Browse if (!browserBound) { return result("unavailable", "not_browser_bound"); } + if (declaresNoCollection(artifact.manifest)) { + return result("unavailable", "not_implemented"); + } if (facts.host && !facts.host.ready) { return result("configure", "host_requirement_unmet", { detail: facts.host.reason ?? null, host: "blocked" }); } @@ -191,6 +210,8 @@ export function readinessReasonMessage(reason: ReadinessReason): string { return "its installed package could not be checked just now"; case "not_browser_bound": return "it does not use browser setup"; + case "not_implemented": + return "it does not collect data yet"; case "host_requirement_unmet": return "this server is not set up to run it yet"; case "runnable": @@ -305,7 +326,7 @@ export function createBrowserEnrollmentReadinessResolver(deps: { ): Promise => { const artifact = await deps.installService.inspectSelected(connectorKey); const host = - artifact.status === "active" && isBrowserBound(artifact.manifest) + artifact.status === "active" && isBrowserBound(artifact.manifest) && !declaresNoCollection(artifact.manifest) ? await hostFor(connectorKey, artifact, reuseHostResult) : null; const hint = diff --git a/reference-implementation/test/browser-enrollment-shell-route.test.ts b/reference-implementation/test/browser-enrollment-shell-route.test.ts index 082d807bc..59b5a5dfe 100644 --- a/reference-implementation/test/browser-enrollment-shell-route.test.ts +++ b/reference-implementation/test/browser-enrollment-shell-route.test.ts @@ -443,6 +443,33 @@ test("browser-enrollment shell: refuses when this host lacks a tool the connecto }); }); +test("browser-enrollment shell: refuses a verified package that declares it does not collect (409)", async () => { + await withServer(async ({ asUrl }) => { + const manifest = loadManifest("chase") as Record; + const placeholder = { + ...manifest, + capabilities: { ...(manifest.capabilities as Record), collection: { implemented: false } }, + }; + const registered = await fixtures?.publish(placeholder); + const resp = await fetch(`${asUrl}/connectors`, { + body: JSON.stringify(registered), + headers: { "Content-Type": "application/json" }, + method: "POST", + }); + assert.equal(resp.status, 201, await resp.text()); + const cookie = await ownerLogin(asUrl); + const res = await fetch(`${asUrl}/_ref/connectors/chase/browser-enrollment-shell`, { + headers: { cookie }, + method: "POST", + }); + assert.equal(res.status, 409); + const error = asRecord((await jsonBody(res)).error); + assert.equal(error.code, "connector_browser_setup_unavailable"); + assert.match(asString(error.message), /not_implemented/); + assert.match(asString(error.message), /does not collect data yet/); + }); +}); + test("browser-enrollment shell: rejects unknown connector (404)", async () => { await withServer(async ({ asUrl }) => { const cookie = await ownerLogin(asUrl); diff --git a/reference-implementation/test/connector-readiness.test.ts b/reference-implementation/test/connector-readiness.test.ts index feb1dc1aa..5a2f64ce0 100644 --- a/reference-implementation/test/connector-readiness.test.ts +++ b/reference-implementation/test/connector-readiness.test.ts @@ -456,3 +456,45 @@ test("the console's readiness vocabulary matches the server's", async () => { assert.deepEqual([...consoleContract.READINESS_ACTIONS], [...READINESS_ACTIONS]); assert.deepEqual([...consoleContract.READINESS_REASONS], [...READINESS_REASONS]); }); + +// ─── A verified package that declares it does not collect ─────────────────── + +test("a verified, browser-bound package that declares no collection is unavailable, not connect", async () => { + let hostAsked = false; + const stub = { + ...activeArtifact(), + manifest: { + capabilities: { collection: { implemented: false } }, + connector_id: "acme-stub", + runtime_requirements: { bindings: BROWSER_BINDINGS }, + }, + }; + const resolver = createBrowserEnrollmentReadinessResolver({ + checkHost: async () => { + hostAsked = true; + return { ready: true }; + }, + installService: { catalog: async () => [], inspectSelected: async () => stub }, + }); + const readiness = await resolver.resolve("acme-stub"); + assert.equal(readiness.action, "unavailable"); + assert.equal(readiness.reason, "not_implemented"); + assert.equal(readiness.browser_bound, true, "the routes stay reachable for an existing connection"); + assert.equal(hostAsked, false, "no host probe for a package that cannot collect"); +}); + +test("only an explicit implemented:false withholds connect; an absent or other declaration does not", async () => { + for (const capabilities of [undefined, {}, { collection: {} }, { collection: { implemented: true } }]) { + const resolver = createBrowserEnrollmentReadinessResolver({ + checkHost: async () => ({ ready: true }), + installService: { + catalog: async () => [], + inspectSelected: async () => ({ + ...activeArtifact(), + manifest: { capabilities, connector_id: "acme-shop", runtime_requirements: { bindings: BROWSER_BINDINGS } }, + }), + }, + }); + assert.equal((await resolver.resolve("acme-shop")).action, "connect", JSON.stringify(capabilities)); + } +}); From 22028845c8f3a983debd7d471cd832ad224070c8 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 00:08:28 -0600 Subject: [PATCH 10/17] fix(console): defer to a pre-readiness server only for browser-bound connectors When the server predates the readiness endpoint the console has no verdict and defers to the server's own enrollment check. It opened the add action for every connector in that case, including ones the bundled manifest does not mark browser-bound; the old rule returned false for those. Both `browserBound` and `canAddAccount` now defer only for a connector the bundled manifest marks browser-bound. Also adds the `not_implemented` reason to the console's copy of the vocabulary, with its fixed note. The drift test against the server's list covers it. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 15 +++++++++++++++ .../(console)/lib/browser-connect-readiness.ts | 11 ++++++----- .../lib/connector-readiness-contract.test.ts | 7 +++++++ .../(console)/lib/connector-readiness-contract.ts | 1 + .../app/(console)/lib/connector-readiness-copy.ts | 2 ++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index 4ea534878..c74259148 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -238,6 +238,21 @@ test("a ready verdict offers account setup with no readiness note; a failed-veri assert.doesNotMatch(needsRepair, BROWSER_SETUP_LINK); }); +test("a verified package that declares no collection shows the placeholder note and no account setup", async () => { + const [installed] = parseConnectorInstallStatusResponse(connectorInstallStatusFixture).data; + assert.ok(installed); + assert.ok(browserPackage.catalog); + const html = await render( + false, + { ...unregisteredBrowser, readiness: verdict("not_implemented", "unavailable", "Declared by the package.") }, + { catalog: { ...browserPackage.catalog, digest: installed.digest }, installed: { ...installed, connector_id: "new-browser" } } + ); + assert.match(html, READINESS_NOTE); + assert.match(html, /placeholder and does not collect data yet/); + assert.doesNotMatch(html, /Declared by the package/); + assert.doesNotMatch(html, BROWSER_SETUP_LINK); +}); + test("an explicit host restriction is displayed without offering Install", async () => { assert.ok(browserPackage.catalog); const html = await render(false, setupCapable, { diff --git a/apps/console/src/app/(console)/lib/browser-connect-readiness.ts b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts index 13d850ac3..bdf643057 100644 --- a/apps/console/src/app/(console)/lib/browser-connect-readiness.ts +++ b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts @@ -18,17 +18,18 @@ export interface BrowserConnectSupport { * endpoint (an older server next to a newer console) returns no verdict. That * server applies its own rule when the enrollment route is called, so the * console defers to it, and uses the bundled manifest to keep the routes - * reachable for connectors it marks browser-bound. Remove this branch, and the - * `isBrowserBoundConnector` import, once no supported server predates the - * endpoint. + * reachable (and the add action open) only for connectors it marks + * browser-bound. Remove this branch, and the `isBrowserBoundConnector` import, + * once no supported server predates the endpoint. */ export function browserConnectSupportFromReadiness( connectorId: string, readiness: ConnectorReadiness | null, serverPredatesReadiness = false ): BrowserConnectSupport { + const deferToOlderServer = serverPredatesReadiness && isBrowserBoundConnector(connectorId); return { - browserBound: readiness ? readiness.browserBound : serverPredatesReadiness && isBrowserBoundConnector(connectorId), - canAddAccount: readiness ? readiness.action === "connect" : serverPredatesReadiness, + browserBound: readiness ? readiness.browserBound : deferToOlderServer, + canAddAccount: readiness ? readiness.action === "connect" : deferToOlderServer, }; } diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts index 22fcd14f4..11f592f22 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -116,6 +116,12 @@ test("the verdict alone decides browserBound; the bundled manifest is consulted "no verdict and no 404 gives no guess from the bundled manifest" ); assert.equal(browserConnectSupportFromReadiness("linkedin", null, true).browserBound, true); + assert.equal( + browserConnectSupportFromReadiness("github", null, true).canAddAccount, + false, + "a pre-readiness server is deferred to only for a connector the bundled manifest marks browser-bound" + ); + assert.equal(browserConnectSupportFromReadiness("linkedin", null, true).canAddAccount, true); assert.equal(browserConnectSupportFromReadiness("some-new-connector", null, true).browserBound, false); }); @@ -127,6 +133,7 @@ test("blocking reasons have fixed owner copy that never carries the server's det assert.match(readinessBlockNote(readiness("retry", "inspection_unavailable"))!, /Retry/); assert.match(readinessBlockNote(readiness("retry", "catalog_unavailable"))!, /catalog could not be reached/); assert.match(readinessBlockNote(readiness("unavailable", "not_published"))!, /not published/); + assert.match(readinessBlockNote(readiness("unavailable", "not_implemented"))!, /placeholder and does not collect data yet/); for (const quiet of [ readiness("connect", "runnable"), readiness("install", "not_installed"), diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts index ce2aa11cd..f66e3633a 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts @@ -22,6 +22,7 @@ export const READINESS_REASONS = [ "artifact_invalid", "inspection_unavailable", "not_browser_bound", + "not_implemented", "host_requirement_unmet", ] as const; export type ConnectorReadinessReason = (typeof READINESS_REASONS)[number]; diff --git a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts index 5f93b162e..e40ff1647 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts @@ -22,6 +22,8 @@ export function readinessBlockNote(readiness: ConnectorReadiness | null | undefi return "The installed connector could not be checked just now. Retry in a moment."; case "catalog_unavailable": return "The connector catalog could not be reached, so this connector's availability is unknown. Retry in a moment."; + case "not_implemented": + return "This connector is a placeholder and does not collect data yet."; case "not_published": return "This connector is not published, and no local source or installed package provides it."; case "not_browser_bound": From 345a1d119adc127f88605f4b59339871a147cac9 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 06:25:01 -0600 Subject: [PATCH 11/17] fix(console): pick the readiness verdict by connector key The readiness endpoint answers for many connectors, but getConnectorReadiness took the first entry of the response. A reordered or multi-connector answer would have applied another connector's verdict to the one the owner asked about. It now selects the entry whose canonical key matches the requested connector and returns null when none matches. The `detail` field is documented as a server diagnostic that the console does not render, matching what the copy module does. It still travels on the wire for the owner-only endpoint. A comment on the UAT static-secret branch records why it is not gated on browser readiness. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...connector-install-client-readiness.test.ts | 66 +++++++++++++++++++ .../(console)/lib/connector-install-client.ts | 13 +++- .../lib/connector-readiness-contract.ts | 2 +- .../(console)/lib/connector-readiness-copy.ts | 2 +- .../server/connector-readiness.ts | 5 +- .../routes/owner-connector-templates.ts | 3 + 6 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 apps/console/src/app/(console)/lib/connector-install-client-readiness.test.ts diff --git a/apps/console/src/app/(console)/lib/connector-install-client-readiness.test.ts b/apps/console/src/app/(console)/lib/connector-install-client-readiness.test.ts new file mode 100644 index 000000000..ad7c5878b --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-install-client-readiness.test.ts @@ -0,0 +1,66 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { afterEach, mock, test } from "node:test"; + +// The client reads the owner session and token before it calls the server; +// replace those so a test controls only the server's answer. +let loaded: Promise | null = null; + +function load() { + loaded ??= (async () => { + mock.module("server-only", { namedExports: {} }); + mock.module(new URL("./verify-session.ts", import.meta.url).href, { + namedExports: { verifyDashboardSession: async () => undefined }, + }); + mock.module(new URL("./owner-token.ts", import.meta.url).href, { + namedExports: { + getOwnerToken: async () => "token", + getRsInternalUrl: () => "http://rs.invalid", + ReferenceServerUnreachableError: class extends Error {}, + ResourceServerHttpError: class extends Error {}, + }, + }); + return import("./connector-install-client.ts"); + })(); + return loaded; +} + +const realFetch = globalThis.fetch; +afterEach(() => { + globalThis.fetch = realFetch; +}); + +function verdict(connectorKey: string, action: string, reason: string) { + return { action, browser_bound: true, connector_key: connectorKey, detail: null, reason }; +} + +function serverAnswers(data: unknown[]) { + globalThis.fetch = (async () => + new Response(JSON.stringify({ data, object: "connector_browser_enrollment_readiness" }), { + headers: { "content-type": "application/json" }, + status: 200, + })) as typeof fetch; +} + +test("the verdict for the requested connector is used even when another connector's comes first", async () => { + const { getConnectorReadiness } = await load(); + serverAnswers([verdict("github", "unavailable", "not_browser_bound"), verdict("linkedin", "connect", "runnable")]); + const readiness = await getConnectorReadiness("linkedin"); + assert.equal(readiness?.connectorKey, "linkedin"); + assert.equal(readiness?.action, "connect"); +}); + +test("a registry-shaped connector id matches the server's canonical key", async () => { + const { getConnectorReadiness } = await load(); + serverAnswers([verdict("github", "unavailable", "not_browser_bound"), verdict("linkedin", "connect", "runnable")]); + const readiness = await getConnectorReadiness("https://registry.pdpp.dev/connectors/linkedin"); + assert.equal(readiness?.connectorKey, "linkedin"); +}); + +test("no verdict is returned when none of the entries is for the requested connector", async () => { + const { getConnectorReadiness } = await load(); + serverAnswers([verdict("github", "connect", "runnable")]); + assert.equal(await getConnectorReadiness("linkedin"), null); +}); diff --git a/apps/console/src/app/(console)/lib/connector-install-client.ts b/apps/console/src/app/(console)/lib/connector-install-client.ts index 7dbf4b0bc..b40cb410b 100644 --- a/apps/console/src/app/(console)/lib/connector-install-client.ts +++ b/apps/console/src/app/(console)/lib/connector-install-client.ts @@ -20,6 +20,7 @@ import { parseConnectorLocalSourcesResponse, parseConnectorInstallStatusResponse, } from "./connector-install-contract.ts"; +import { canonicalConnectorKey } from "./connection-modality.ts"; import { type ConnectorReadiness, parseConnectorReadinessResponse } from "./connector-readiness-contract.ts"; import { describeErrorText } from "./describe-error.ts"; import { @@ -106,12 +107,20 @@ export async function listConnectorLocalSources(): Promise { const payload = await connectorInstallFetch( `/v1/owner/connector-install/readiness?connector_id=${encodeURIComponent(connectorId)}` ); - return parseConnectorReadinessResponse(payload)[0] ?? null; + const requestedKey = canonicalConnectorKey(connectorId); + const match = parseConnectorReadinessResponse(payload).find( + (entry) => canonicalConnectorKey(entry.connectorKey) === requestedKey + ); + return match ?? null; } /** diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts index f66e3633a..dac691870 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts @@ -31,7 +31,7 @@ export interface ConnectorReadiness { readonly action: ConnectorReadinessAction; readonly browserBound: boolean; readonly connectorKey: string; - /** Owner-readable cause for repair, configure and retry. Null otherwise. */ + /** Server diagnostic for repair, configure and retry. Null otherwise. The console does not render it. */ readonly detail: string | null; readonly reason: ConnectorReadinessReason; } diff --git a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts index e40ff1647..8e2141ac9 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-copy.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts @@ -7,7 +7,7 @@ import type { ConnectorReadiness } from "./connector-readiness-contract.ts"; * Owner-facing text for a readiness verdict that blocks setup, or null when the * verdict needs no explanation of its own: `connect` needs none, and `install` * is the package column's own Install action. The text is fixed per reason: the - * server's `detail` is diagnostic and is never shown to the owner. + * server's `detail` is a diagnostic that the console does not render. */ export function readinessBlockNote(readiness: ConnectorReadiness | null | undefined): string | null { if (!readiness) { diff --git a/reference-implementation/server/connector-readiness.ts b/reference-implementation/server/connector-readiness.ts index c15682c7f..fbfdb198e 100644 --- a/reference-implementation/server/connector-readiness.ts +++ b/reference-implementation/server/connector-readiness.ts @@ -53,7 +53,10 @@ export interface BrowserEnrollmentReadiness { readonly browser_bound: boolean; readonly catalog: CatalogFact; readonly connector_key: string; - /** Owner-readable cause for repair, configure and retry. Null otherwise. */ + /** + * Server diagnostic for repair, configure and retry. It can name store errors + * and host paths. The console does not render it. Null otherwise. + */ readonly detail: string | null; readonly host: "ready" | "blocked" | "not_evaluated"; readonly reason: ReadinessReason; diff --git a/reference-implementation/server/routes/owner-connector-templates.ts b/reference-implementation/server/routes/owner-connector-templates.ts index 39d4dc9b9..7da8a5e2a 100644 --- a/reference-implementation/server/routes/owner-connector-templates.ts +++ b/reference-implementation/server/routes/owner-connector-templates.ts @@ -231,6 +231,9 @@ function isUatExposablePlan( const basePlan = [ isOwnerActionablePlan(plan, browserRunnable), plan.catalogDisposition === "static_secret_experimental", + // Not gated on `browserRunnable`: a connector that declares credential + // capture takes its secret through the setup form and never opens the + // browser shell, so browser readiness does not apply to this entry. plan.catalogDisposition === "static_secret_connect" && plan.setupModality === "static_secret" && staticSecretCredentialCaptureFromManifest(manifest) !== null, From c2aa299a18ba64c2f8e36f307734a6c5066f0560 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 07:19:31 -0600 Subject: [PATCH 12/17] fix(console): offer Install for a browser connector whose setup comes from its package The rule that withholds Install from a connector with no setup path says that installing a package never creates one. That held while a bundled list decided browser setup. Now the server's verdict decides, and for a browser connector it is the package that makes the connector runnable: a manifest-only entry (no template yet) reads as a runbook disposition, so it would show "Not installable" and could never be installed. A browser-bound entry in an offered tier now counts as awaiting its package when it has no verdict and is not registered, or when the verdict is install or repair. Any other verdict (placeholder, host requirement, not browser-bound) still withholds Install. While the package catalog is busy the offer stays "unknown", as before. The earlier runbook fixture used a verdict-less entry to mean "no path"; it now carries the verdict that says installing cannot help. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 48 ++++++++++++++++++- .../lib/source-setup-presentation.ts | 31 +++++++++--- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index c74259148..10a2e1fae 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -87,6 +87,8 @@ const UNAVAILABLE_TRUE = /data-row-unavailable="true"/; const UNAVAILABLE_DISCLOSURE = /data-testid="unavailable-connectors-disclosure"/; const BROWSER_SETUP_LINK = /href="\/connect\/browser-session\//; const INSTALL_OR_BROWSER_SETUP = />Install<\/button>|href="\/connect\/browser-session\//; +const NOT_INSTALLABLE = /Not installable: no setup path is available here\./; +const INSTALL_DISABLED = /data-testid="connector-install-disabled"/; const HOST_BLOCK = /Host blocked: Browser unavailable here\./; const CANNOT_ADD_HERE = /1 connector cannot be added here/; const PLATFORM_COPY = /not available on this platform/; @@ -158,6 +160,42 @@ test("a published, uninstalled browser connector offers Install and is never 'No assert.match(html, UNAVAILABLE_FALSE); }); +// A browser connector's setup path comes from its package: before the reference +// has registered it there is no verdict, and installing is the step that lets +// the server decide. The page builds such entries from the manifest alone. +test("a manifest-only browser connector with a published package offers Install, not 'Not installable'", async () => { + const { registrationStatus: _omitted, ...manifestOnly } = unregisteredBrowser; + const entry = { ...manifestOnly, readiness: null }; + const html = await render(false, entry, browserPackage); + assert.match(html, INSTALL_BUTTON); + assert.doesNotMatch(html, NOT_INSTALLABLE); + assert.match(html, UNAVAILABLE_FALSE); + + // While the package catalog is busy the offer is unknown, which is not "cannot install". + const busy = await render(true, entry, browserPackage); + assert.doesNotMatch(busy, INSTALL_BUTTON); + assert.doesNotMatch(busy, NOT_INSTALLABLE); + assert.match(busy, UNKNOWN_PACKAGE); +}); + +test("installing is not offered when the verdict says installing cannot make the connector runnable", async () => { + for (const [reason, action] of [ + ["not_implemented", "unavailable"], + ["host_requirement_unmet", "configure"], + ["not_browser_bound", "unavailable"], + ] as const) { + const html = await render(false, { ...unregisteredBrowser, readiness: verdict(reason, action) }, browserPackage); + assert.doesNotMatch(html, INSTALL_BUTTON, reason); + assert.match(html, NOT_INSTALLABLE, reason); + } +}); + +test("a development-tier manifest-only browser connector is not offered for install", async () => { + const { registrationStatus: _omitted, ...manifestOnly } = unregisteredBrowser; + const html = await render(false, { ...manifestOnly, publicTier: "development", readiness: null }, browserPackage); + assert.doesNotMatch(html, INSTALL_BUTTON); +}); + test("a verdict that blocks setup shows its reason and offers no account setup", async () => { const [installed] = parseConnectorInstallStatusResponse(connectorInstallStatusFixture).data; assert.ok(installed); @@ -186,7 +224,6 @@ const DISPOSITIONS_WITHOUT_SETUP_PATH = [ "unknown_unsupported", "browser_bound_runbook", ] as const; -const NOT_INSTALLABLE = /Not installable: no setup path is available here\./; for (const disposition of DISPOSITIONS_WITHOUT_SETUP_PATH) { test(`${disposition} never offers an install control and sorts into the collapsed footer`, async () => { @@ -194,6 +231,9 @@ for (const disposition of DISPOSITIONS_WITHOUT_SETUP_PATH) { ...unregisteredBrowser, disposition, ownerActionable: undefined, + // For a browser-bound connector the disposition alone no longer decides: + // the server's verdict does, and this one says installing cannot help. + readiness: verdict("not_implemented", "unavailable"), supportState: "unsupported", }; for (const busy of [false, true]) { @@ -294,7 +334,11 @@ test("after installation account setup appears only when the setup planner suppo catalog: { ...browserPackage.catalog, digest: installed.digest }, installed: { ...installed, connector_id: "new-browser" }, }; - const stillBlocked = await render(false, unregisteredBrowser, lifecycle); + const stillBlocked = await render( + false, + { ...unregisteredBrowser, readiness: verdict("host_requirement_unmet", "configure") }, + lifecycle + ); assert.doesNotMatch(stillBlocked, INSTALL_OR_BROWSER_SETUP); assert.match(stillBlocked, UNAVAILABLE_TRUE); diff --git a/apps/console/src/app/(console)/lib/source-setup-presentation.ts b/apps/console/src/app/(console)/lib/source-setup-presentation.ts index a2679ed36..26bee51c1 100644 --- a/apps/console/src/app/(console)/lib/source-setup-presentation.ts +++ b/apps/console/src/app/(console)/lib/source-setup-presentation.ts @@ -184,15 +184,34 @@ export function sourceSetupRank(entry: ConnectorCatalogEntry): number { } /** - * Whether account setup has no action for this disposition and tier. The - * answer does not depend on package state: installing a package makes the - * existing setup path reachable but never creates one, so an entry for which - * this is true is not installable either. Entries without a `registrationStatus` + * A browser connector's account setup comes from its package. Before the + * reference has registered it there is no verdict (the page builds such entries + * from the manifest alone), and a verdict of `install` or `repair` names the + * package as the next step; the server then decides whether the connector can + * run. Any other verdict says installing cannot help. Only an offered tier + * qualifies: a development or undeclared tier is never an add offer. + */ +function browserSetupAwaitsPackage(entry: ConnectorCatalogEntry): boolean { + if (entry.modality !== "browser_bound" || (entry.publicTier !== "supported" && entry.publicTier !== "preview")) { + return false; + } + if (entry.readiness) { + return entry.readiness.action === "install" || entry.readiness.action === "repair"; + } + return entry.registrationStatus !== "registered"; +} + +/** + * Whether account setup has no action for this disposition and tier, and no + * package step can create one. For most dispositions installing a package makes + * an existing setup path reachable but never creates one, so an entry for which + * this is true is not installable either. A browser connector is the exception: + * see {@link browserSetupAwaitsPackage}. Entries without a `registrationStatus` * (manifest-only entries, the ones that precede their package) are judged on - * disposition and tier alone. + * disposition and tier alone, except for that case. */ export function sourceSetupRowIsUnavailable(entry: ConnectorCatalogEntry): boolean { - return sourceSetupAction(entry) === null; + return sourceSetupAction(entry) === null && !browserSetupAwaitsPackage(entry); } /** The owner-facing status label + tone for first-account setup. */ From b926675fabea6671b21e2fe7b516b4c361a4ccee Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 07:19:31 -0600 Subject: [PATCH 13/17] fix(reference): gate UAT exposure of a browser-bound credential connector on runnable browser setup A connector that declares credential capture is exposed for UAT without asking whether its setup can run. For a browser-bound connector that is wrong: it takes stored credentials but is still set up through the owner's browser session, so with no verified package it was exposed with no way to complete setup. A connector that is not browser-bound has no such dependency and stays exposed. The check is one helper used by the base rule and the development-tier allowlist rule, which had the same gap. It replaces a comment that wrongly said credential-capture connectors never open the browser shell. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../routes/owner-connector-templates.ts | 36 +++++---- .../test/owner-connector-templates.test.ts | 75 +++++++++++++++++++ 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/reference-implementation/server/routes/owner-connector-templates.ts b/reference-implementation/server/routes/owner-connector-templates.ts index 7da8a5e2a..e9aa46b5e 100644 --- a/reference-implementation/server/routes/owner-connector-templates.ts +++ b/reference-implementation/server/routes/owner-connector-templates.ts @@ -221,6 +221,25 @@ function isOwnerActionablePlan(plan: ReturnType return isSupportedOwnerActionPlan(plan) || isOwnerSessionBrowserActionPlan(plan, browserRunnable); } +/** + * A static-secret connector that declares credential capture has a setup path + * to try in UAT. A browser-bound one (stored credentials plus an interactive + * login) is still set up through the owner's browser session, so it needs a + * runnable browser setup; one that is not browser-bound has no such dependency. + */ +function hasUatStaticSecretSetup( + plan: ReturnType, + manifest: ConnectorManifestLike, + browserRunnable: boolean +): boolean { + return ( + plan.catalogDisposition === "static_secret_connect" && + plan.setupModality === "static_secret" && + staticSecretCredentialCaptureFromManifest(manifest) !== null && + (plan.connectorModality !== "browser_bound" || browserRunnable) + ); +} + function isUatExposablePlan( plan: ReturnType, manifest: ConnectorManifestLike, @@ -231,12 +250,7 @@ function isUatExposablePlan( const basePlan = [ isOwnerActionablePlan(plan, browserRunnable), plan.catalogDisposition === "static_secret_experimental", - // Not gated on `browserRunnable`: a connector that declares credential - // capture takes its secret through the setup form and never opens the - // browser shell, so browser readiness does not apply to this entry. - plan.catalogDisposition === "static_secret_connect" && - plan.setupModality === "static_secret" && - staticSecretCredentialCaptureFromManifest(manifest) !== null, + hasUatStaticSecretSetup(plan, manifest, browserRunnable), ].includes(true); if (basePlan) { @@ -249,11 +263,7 @@ function isUatExposablePlan( const listing = manifest.capabilities?.public_listing; const isDevelopment = listing?.tier === "development"; if (isDevelopment) { - const hasValidSetup = - plan.catalogDisposition === "static_secret_connect" && - plan.setupModality === "static_secret" && - staticSecretCredentialCaptureFromManifest(manifest) !== null; - return hasValidSetup; + return hasUatStaticSecretSetup(plan, manifest, browserRunnable); } } @@ -425,9 +435,7 @@ async function readinessByConnectorKey( ): Promise> { const browserBound = templates.flatMap((manifest) => { const connectorKey = connectorKeyFromManifest(ctx, manifest); - return connectorKey && classifyConnectorIntentModality(manifest) === "browser_bound" - ? [connectorKey] - : []; + return connectorKey && classifyConnectorIntentModality(manifest) === "browser_bound" ? [connectorKey] : []; }); const resolved = await ctx.readinessResolver.resolveMany(browserBound); return new Map(resolved.map((entry) => [entry.connector_key, entry])); diff --git a/reference-implementation/test/owner-connector-templates.test.ts b/reference-implementation/test/owner-connector-templates.test.ts index 27325238d..7acdb3b72 100644 --- a/reference-implementation/test/owner-connector-templates.test.ts +++ b/reference-implementation/test/owner-connector-templates.test.ts @@ -984,3 +984,78 @@ test("allowlist parser: rejects malformed entries, admits valid connector keys", } } }); + +// A browser-bound connector with stored credentials is still set up through the +// owner's browser session, so its UAT exposure needs a runnable browser setup. +// A connector that is not browser-bound has no such dependency. +test("UAT exposure of a browser-bound credential connector requires runnable browser setup", async () => { + const priorEnv = { + allowlist: process.env.PDPP_UAT_CONNECTOR_ALLOWLIST, + uat: process.env.PDPP_EXPOSE_UNPROVEN_CONNECTORS_UAT, + }; + const developmentHeb = (): Record => { + const manifest = loadManifest("heb"); + return { + ...manifest, + capabilities: { ...asRecord(manifest.capabilities), public_listing: { tier: "development" } }, + }; + }; + const scenarios = [ + { exposed: true, label: "preview, package published", manifest: () => loadManifest("heb"), publish: true }, + { exposed: false, label: "preview, no package", manifest: () => loadManifest("heb"), publish: false }, + { + allowlist: "heb", + exposed: true, + label: "allowlisted development, package published", + manifest: developmentHeb, + publish: true, + }, + { + allowlist: "heb", + exposed: false, + label: "allowlisted development, no package", + manifest: developmentHeb, + publish: false, + }, + { + exposed: true, + key: "gmail", + label: "not browser-bound, unaffected by browser setup", + manifest: () => loadManifest("gmail"), + publish: false, + }, + ]; + try { + for (const scenario of scenarios) { + process.env.PDPP_EXPOSE_UNPROVEN_CONNECTORS_UAT = "1"; + if (scenario.allowlist) { + process.env.PDPP_UAT_CONNECTOR_ALLOWLIST = scenario.allowlist; + } else { + delete process.env.PDPP_UAT_CONNECTOR_ALLOWLIST; + } + await withServer(async ({ asUrl, rsUrl }) => { + await registerConnector(asUrl, scenario.manifest(), { publish: scenario.publish }); + const ownerToken = await issueOwnerToken(asUrl); + const { body } = await fetchJson(`${rsUrl}/v1/owner/connector-templates`, { + headers: { Authorization: `Bearer ${ownerToken}` }, + }); + assert.equal( + byConnector(body, scenario.key ?? "heb").uat_expose_unlisted_connectors, + scenario.exposed, + scenario.label + ); + }); + } + } finally { + for (const [name, value] of [ + ["PDPP_UAT_CONNECTOR_ALLOWLIST", priorEnv.allowlist], + ["PDPP_EXPOSE_UNPROVEN_CONNECTORS_UAT", priorEnv.uat], + ] as const) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + } +}); From 1446e952b5fca00a217b56947d616ad11dac84c9 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 08:25:58 -0600 Subject: [PATCH 14/17] test(reference): import the console readiness vocabulary without type-checking it here reference-implementation's tsc program statically resolved the console contract file through the readiness vocabulary test's literal import and failed with TS1287 four times, because the console package is compiled as CommonJS under this package's verbatimModuleSyntax. `tsc --noEmit` in reference-implementation therefore exited 1. Build the specifier at runtime and type the result by hand, the way sources-report-model.test.ts already imports console modules. The test still loads the real file at runtime and still compares both vocabularies. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../test/connector-readiness.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/reference-implementation/test/connector-readiness.test.ts b/reference-implementation/test/connector-readiness.test.ts index 5a2f64ce0..cd5f78fbf 100644 --- a/reference-implementation/test/connector-readiness.test.ts +++ b/reference-implementation/test/connector-readiness.test.ts @@ -17,9 +17,9 @@ import test from "node:test"; import { type ConnectorCatalogEntry, + type ConnectorInstallService, createConnectorInstallService, createFileConnectorInstallStore, - type ConnectorInstallService, } from "../server/connector-install/index.ts"; import { createFileLocalConnectorSourceStore } from "../server/connector-install/local-source.ts"; import { @@ -450,8 +450,13 @@ test("the console's readiness vocabulary matches the server's", async () => { // The console cannot import the server module (it pulls in the install // service), so it keeps its own copy of the vocabulary. This test is the // single check that the two copies agree. - const consoleContract = await import( - "../../apps/console/src/app/(console)/lib/connector-readiness-contract.ts" + // + // The specifier is built at runtime and the result typed by hand, so tsc + // never resolves apps/console's module graph under this package's compiler + // options (apps/console has its own tsconfig and typecheck script). + const consoleLibDirectory = ["..", "..", "apps", "console", "src", "app", "(console)", "lib"].join("/"); + const consoleContract: { READINESS_ACTIONS: readonly string[]; READINESS_REASONS: readonly string[] } = await import( + `${consoleLibDirectory}/connector-readiness-contract.ts` ); assert.deepEqual([...consoleContract.READINESS_ACTIONS], [...READINESS_ACTIONS]); assert.deepEqual([...consoleContract.READINESS_REASONS], [...READINESS_REASONS]); From 823e30b17754e556ab3946e83235a52b1f8a7c9c Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 08:25:58 -0600 Subject: [PATCH 15/17] style: sort imports and drop non-null assertions in files this PR owns biome's import-order assist flagged five files this PR added or edited, and the new console contract test used six non-null assertions. Sort the imports and replace the assertions with one helper that asserts the owner copy exists. No behaviour change. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../lib/connector-readiness-contract.test.ts | 21 ++++++++++++------- .../server/connector-readiness.ts | 4 ++-- .../browser-enrollment-shell-route.test.ts | 4 ++-- .../test/connector-readiness-routes.test.ts | 2 +- .../test/owner-connector-templates.test.ts | 3 +-- 5 files changed, 20 insertions(+), 14 deletions(-) diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts index 11f592f22..53eaeccda 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -4,7 +4,6 @@ import assert from "node:assert/strict"; import test from "node:test"; import { browserConnectSupportFromReadiness } from "./browser-connect-readiness.ts"; -import { readinessBlockNote } from "./connector-readiness-copy.ts"; import { type ConnectorReadiness, parseConnectorReadiness, @@ -12,6 +11,7 @@ import { READINESS_ACTIONS, READINESS_REASONS, } from "./connector-readiness-contract.ts"; +import { readinessBlockNote } from "./connector-readiness-copy.ts"; const wire = (overrides: Record = {}) => ({ action: "connect", @@ -125,15 +125,22 @@ test("the verdict alone decides browserBound; the bundled manifest is consulted assert.equal(browserConnectSupportFromReadiness("some-new-connector", null, true).browserBound, false); }); +/** The owner copy for a verdict that must have some. */ +function blockNote(verdict: ReturnType): string { + const note = readinessBlockNote(verdict); + assert.ok(note !== null, "expected owner copy for a blocking verdict"); + return note; +} + test("blocking reasons have fixed owner copy that never carries the server's detail; others have none", () => { - const configure = readinessBlockNote(readiness("configure", "host_requirement_unmet", "No display at /srv/run."))!; + const configure = blockNote(readiness("configure", "host_requirement_unmet", "No display at /srv/run.")); assert.match(configure, /not set up to run this connector yet\./); assert.doesNotMatch(configure, /No display|\/srv/); - assert.match(readinessBlockNote(readiness("repair", "artifact_invalid"))!, /failed verification/); - assert.match(readinessBlockNote(readiness("retry", "inspection_unavailable"))!, /Retry/); - assert.match(readinessBlockNote(readiness("retry", "catalog_unavailable"))!, /catalog could not be reached/); - assert.match(readinessBlockNote(readiness("unavailable", "not_published"))!, /not published/); - assert.match(readinessBlockNote(readiness("unavailable", "not_implemented"))!, /placeholder and does not collect data yet/); + assert.match(blockNote(readiness("repair", "artifact_invalid")), /failed verification/); + assert.match(blockNote(readiness("retry", "inspection_unavailable")), /Retry/); + assert.match(blockNote(readiness("retry", "catalog_unavailable")), /catalog could not be reached/); + assert.match(blockNote(readiness("unavailable", "not_published")), /not published/); + assert.match(blockNote(readiness("unavailable", "not_implemented")), /placeholder and does not collect data yet/); for (const quiet of [ readiness("connect", "runnable"), readiness("install", "not_installed"), diff --git a/reference-implementation/server/connector-readiness.ts b/reference-implementation/server/connector-readiness.ts index fbfdb198e..f2a07b805 100644 --- a/reference-implementation/server/connector-readiness.ts +++ b/reference-implementation/server/connector-readiness.ts @@ -20,9 +20,9 @@ */ import { checkAttendedEnrollmentHostReadiness } from "../runtime/scheduler-readiness.ts"; -import { classifyConnectorIntentModality, type ConnectorManifestLike } from "./connection-setup-plan.ts"; -import { canonicalConnectorKey } from "./connector-key.ts"; +import { type ConnectorManifestLike, classifyConnectorIntentModality } from "./connection-setup-plan.ts"; import type { ConnectorCatalogEntry, ConnectorInstallService, SelectedConnectorArtifact } from "./connector-install/index.ts"; +import { canonicalConnectorKey } from "./connector-key.ts"; export const READINESS_ACTIONS = ["connect", "install", "repair", "configure", "retry", "unavailable"] as const; export type ReadinessAction = (typeof READINESS_ACTIONS)[number]; diff --git a/reference-implementation/test/browser-enrollment-shell-route.test.ts b/reference-implementation/test/browser-enrollment-shell-route.test.ts index 59b5a5dfe..a0f47d8c8 100644 --- a/reference-implementation/test/browser-enrollment-shell-route.test.ts +++ b/reference-implementation/test/browser-enrollment-shell-route.test.ts @@ -11,13 +11,13 @@ import { expiredEnrollmentShellIds, retireExpiredBrowserEnrollmentShells, } from "../server/browser-enrollment-shell-retirement.ts"; -import { getDb } from "../server/db.ts"; import { createConnectorInstallService } from "../server/connector-install/index.ts"; import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; +import { getDb } from "../server/db.ts"; import { startServer } from "../server/index.ts"; -import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; import { BROWSER_ENROLLMENT_SHELL_TTL_MS } from "../server/routes/ref-browser-enrollment-shell.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; +import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; // Integration coverage for the browser-enrollment shell routes: // POST /_ref/connectors/:connectorId/browser-enrollment-shell (on AS) diff --git a/reference-implementation/test/connector-readiness-routes.test.ts b/reference-implementation/test/connector-readiness-routes.test.ts index f5d58d63b..a4e6e6648 100644 --- a/reference-implementation/test/connector-readiness-routes.test.ts +++ b/reference-implementation/test/connector-readiness-routes.test.ts @@ -17,9 +17,9 @@ import { readPolyfillManifests } from "@pdpp/polyfill-connectors/manifests"; import { createConnectorInstallService } from "../server/connector-install/index.ts"; import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; import { startServer } from "../server/index.ts"; -import { closeTestServer } from "./helpers/close-test-server.ts"; import { TEST_PRE_REGISTERED_PUBLIC_CLIENTS } from "./fixtures/demo-clients.ts"; import { createBrowserArtifactFixtures, setBrowserHostCapability } from "./helpers/browser-artifact-fixtures.ts"; +import { closeTestServer } from "./helpers/close-test-server.ts"; const CATALOG_DIGEST = `sha256:${"a".repeat(64)}`; diff --git a/reference-implementation/test/owner-connector-templates.test.ts b/reference-implementation/test/owner-connector-templates.test.ts index 7acdb3b72..dd6fa7b82 100644 --- a/reference-implementation/test/owner-connector-templates.test.ts +++ b/reference-implementation/test/owner-connector-templates.test.ts @@ -14,10 +14,9 @@ import assert from "node:assert/strict"; import test from "node:test"; import { readPolyfillManifests } from "@pdpp/polyfill-connectors/manifests"; - -import { canonicalConnectorKey } from "../server/connector-key.ts"; import { createConnectorInstallService } from "../server/connector-install/index.ts"; import { installedConnectorManifestRegistration } from "../server/connector-install/manifest-registration.ts"; +import { canonicalConnectorKey } from "../server/connector-key.ts"; import { startServer } from "../server/index.ts"; import { createSqliteConnectorInstanceStore } from "../server/stores/connector-instance-store.ts"; import { TEST_PRE_REGISTERED_PUBLIC_CLIENTS } from "./fixtures/demo-clients.ts"; From 2223a675d81d7720ed18b07848f65a0cb8c0b4ec Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 08:28:05 -0600 Subject: [PATCH 16/17] fix(console): leave a connector that has a template but no verdict to the disposition rule A browser connector with no readiness verdict counted as awaiting its package whenever it was not registered. That also matched an entry built from a template that a server without the readiness endpoint reports with no verdict, so a connector whose disposition has no setup path (provider-auth gated, unsupported) offered Install on a page where the earlier rule says "Not installable". Five of that rule's own tests failed. Only a manifest-only entry, which has no registrationStatus because no template exists yet, awaits its package without a verdict. The earlier revision hid the problem by giving those tests a blocking verdict; they are back to their original form and pass. Assisted-by: AI Signed-off-by: Tim Nunamaker --- .../components/source-setup-catalog.test.ts | 9 +-------- .../app/(console)/lib/source-setup-presentation.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts index 10a2e1fae..e41bd2f6b 100644 --- a/apps/console/src/app/(console)/components/source-setup-catalog.test.ts +++ b/apps/console/src/app/(console)/components/source-setup-catalog.test.ts @@ -231,9 +231,6 @@ for (const disposition of DISPOSITIONS_WITHOUT_SETUP_PATH) { ...unregisteredBrowser, disposition, ownerActionable: undefined, - // For a browser-bound connector the disposition alone no longer decides: - // the server's verdict does, and this one says installing cannot help. - readiness: verdict("not_implemented", "unavailable"), supportState: "unsupported", }; for (const busy of [false, true]) { @@ -334,11 +331,7 @@ test("after installation account setup appears only when the setup planner suppo catalog: { ...browserPackage.catalog, digest: installed.digest }, installed: { ...installed, connector_id: "new-browser" }, }; - const stillBlocked = await render( - false, - { ...unregisteredBrowser, readiness: verdict("host_requirement_unmet", "configure") }, - lifecycle - ); + const stillBlocked = await render(false, unregisteredBrowser, lifecycle); assert.doesNotMatch(stillBlocked, INSTALL_OR_BROWSER_SETUP); assert.match(stillBlocked, UNAVAILABLE_TRUE); diff --git a/apps/console/src/app/(console)/lib/source-setup-presentation.ts b/apps/console/src/app/(console)/lib/source-setup-presentation.ts index 26bee51c1..03940bab1 100644 --- a/apps/console/src/app/(console)/lib/source-setup-presentation.ts +++ b/apps/console/src/app/(console)/lib/source-setup-presentation.ts @@ -184,11 +184,13 @@ export function sourceSetupRank(entry: ConnectorCatalogEntry): number { } /** - * A browser connector's account setup comes from its package. Before the - * reference has registered it there is no verdict (the page builds such entries - * from the manifest alone), and a verdict of `install` or `repair` names the - * package as the next step; the server then decides whether the connector can - * run. Any other verdict says installing cannot help. Only an offered tier + * A browser connector's account setup comes from its package. A manifest-only + * entry (the page builds it before the reference has a template, so it has + * neither a verdict nor a `registrationStatus`) awaits its package, and so does + * an entry whose verdict is `install` or `repair`; the server then decides + * whether the connector can run. Any other verdict says installing cannot help. + * An entry with a `registrationStatus` but no verdict (a server that predates + * the readiness endpoint) keeps the disposition's answer. Only an offered tier * qualifies: a development or undeclared tier is never an add offer. */ function browserSetupAwaitsPackage(entry: ConnectorCatalogEntry): boolean { @@ -198,7 +200,7 @@ function browserSetupAwaitsPackage(entry: ConnectorCatalogEntry): boolean { if (entry.readiness) { return entry.readiness.action === "install" || entry.readiness.action === "repair"; } - return entry.registrationStatus !== "registered"; + return entry.registrationStatus === undefined; } /** From b020485ff1270d7e0a0de7b5a44204edcb848c90 Mon Sep 17 00:00:00 2001 From: Tim Nunamaker Date: Fri, 6 Nov 2026 11:04:40 -0600 Subject: [PATCH 17/17] fix(console): keep the owner-token install client out of the connector-package bundle module The desktop build stages the console and then probes it (scripts/check-staged-console-connectors.mjs): it evaluates every webpack module whose text names the connector conformance roster package, loading only .next/server/chunks. Selecting a readiness verdict by key made connector-install-client.ts import connection-modality.ts, which imports the shared setup planner and, through it, polyfill-connectors-runtime. Webpack concatenated the client, the owner-token code it imports and that runtime into one module that names the roster. Evaluating it needed next/headers externals that live only in route entry files, so the probe threw "e[a] is not a function" and all four desktop builds failed. Move the key rule into the pure connector-readiness-contract.ts (readinessConnectorKey, selectConnectorReadiness) and have the client import that. A test pins the copy to the console's canonicalConnectorKey, and a second test fails if the client reaches the setup planner or the connector package again. Reproduced locally by building the console and running the staging probe: it failed before this change and passes after it. Assisted-by: AI Signed-off-by: Tim Nunamaker --- ...nector-install-client.import-graph.test.ts | 55 +++++++++++++++++++ .../(console)/lib/connector-install-client.ts | 13 ++--- .../lib/connector-readiness-contract.test.ts | 26 +++++++++ .../lib/connector-readiness-contract.ts | 28 ++++++++++ 4 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 apps/console/src/app/(console)/lib/connector-install-client.import-graph.test.ts diff --git a/apps/console/src/app/(console)/lib/connector-install-client.import-graph.test.ts b/apps/console/src/app/(console)/lib/connector-install-client.import-graph.test.ts new file mode 100644 index 000000000..432734569 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-install-client.import-graph.test.ts @@ -0,0 +1,55 @@ +// Copyright The PDP-Connect Contributors +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// The staged-console probe (scripts/check-staged-console-connectors.mjs) +// evaluates every webpack module whose text names the conformance roster +// package. Webpack concatenates a module into its importer, so any module the +// owner-token client reaches that also reaches polyfill-connectors-runtime can +// land in that set. The client imports owner-token (next/headers); the probe +// loads only `.next/server/chunks`, where next/headers' externals are absent, +// so evaluating it throws "e[a] is not a function" and every desktop build +// fails. The client therefore must not reach the shared setup planner. +const LIB_DIRECTORY = dirname(fileURLToPath(import.meta.url)); +const FORBIDDEN_SPECIFIERS = [ + "pdpp-reference-implementation/connection-setup-plan", + "polyfill-connectors-runtime", + "@pdpp/polyfill-connectors", +]; + +function importSpecifiers(source: string): string[] { + return [...source.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)].map((match) => match[1] ?? ""); +} + +/** Every module reachable from `entry` by relative imports, with the chain that reached a forbidden one. */ +function forbiddenChains(entry: string): string[] { + const chains: string[] = []; + const seen = new Set(); + const visit = (file: string, chain: readonly string[]) => { + if (seen.has(file)) { + return; + } + seen.add(file); + for (const specifier of importSpecifiers(readFileSync(file, "utf8"))) { + if (FORBIDDEN_SPECIFIERS.some((forbidden) => specifier.includes(forbidden))) { + chains.push([...chain, specifier].join(" -> ")); + } else if (specifier.startsWith(".")) { + const next = join(dirname(file), specifier); + if (existsSync(next)) { + visit(next, [...chain, specifier]); + } + } + } + }; + visit(join(LIB_DIRECTORY, entry), [entry]); + return chains; +} + +test("the owner-token install client does not reach the shared setup planner or the connector package", () => { + assert.deepEqual(forbiddenChains("connector-install-client.ts"), []); +}); diff --git a/apps/console/src/app/(console)/lib/connector-install-client.ts b/apps/console/src/app/(console)/lib/connector-install-client.ts index b40cb410b..ce85d7450 100644 --- a/apps/console/src/app/(console)/lib/connector-install-client.ts +++ b/apps/console/src/app/(console)/lib/connector-install-client.ts @@ -20,8 +20,11 @@ import { parseConnectorLocalSourcesResponse, parseConnectorInstallStatusResponse, } from "./connector-install-contract.ts"; -import { canonicalConnectorKey } from "./connection-modality.ts"; -import { type ConnectorReadiness, parseConnectorReadinessResponse } from "./connector-readiness-contract.ts"; +import { + type ConnectorReadiness, + parseConnectorReadinessResponse, + selectConnectorReadiness, +} from "./connector-readiness-contract.ts"; import { describeErrorText } from "./describe-error.ts"; import { isConnectorInstallRouteUnavailableError, @@ -116,11 +119,7 @@ export async function getConnectorReadiness(connectorId: string): Promise canonicalConnectorKey(entry.connectorKey) === requestedKey - ); - return match ?? null; + return selectConnectorReadiness(parseConnectorReadinessResponse(payload), connectorId); } /** diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts index 53eaeccda..7c9f7c8e3 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -4,12 +4,15 @@ import assert from "node:assert/strict"; import test from "node:test"; import { browserConnectSupportFromReadiness } from "./browser-connect-readiness.ts"; +import { canonicalConnectorKey } from "./connection-modality.ts"; import { type ConnectorReadiness, parseConnectorReadiness, parseConnectorReadinessResponse, READINESS_ACTIONS, READINESS_REASONS, + readinessConnectorKey, + selectConnectorReadiness, } from "./connector-readiness-contract.ts"; import { readinessBlockNote } from "./connector-readiness-copy.ts"; @@ -150,3 +153,26 @@ test("blocking reasons have fixed owner copy that never carries the server's det } assert.equal(readinessBlockNote(null), null); }); + +test("the readiness key rule answers the same as the console's canonical connector key", () => { + for (const id of [ + "linkedin", + "https://registry.pdpp.dev/connectors/linkedin", + "https://registry.pdpp.dev/connectors/linkedin/", + " https://registry.pdpp.dev/connectors/linkedin ", + " goodreads ", + "https://example.test/connectors/custom", + "", + ]) { + assert.equal(readinessConnectorKey(id), canonicalConnectorKey(id), JSON.stringify(id)); + } +}); + +test("a verdict is chosen by connector key, in any position, and none matches to null", () => { + const first = readiness("connect", "runnable"); + const second = { ...readiness("install", "not_installed"), connectorKey: "goodreads" }; + assert.equal(selectConnectorReadiness([first, second], "https://registry.pdpp.dev/connectors/goodreads"), second); + assert.equal(selectConnectorReadiness([first, second], first.connectorKey), first); + assert.equal(selectConnectorReadiness([first, second], "missing"), null); + assert.equal(selectConnectorReadiness([], first.connectorKey), null); +}); diff --git a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts index dac691870..a3f99982c 100644 --- a/apps/console/src/app/(console)/lib/connector-readiness-contract.ts +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts @@ -83,3 +83,31 @@ export function parseConnectorReadinessResponse(payload: unknown): readonly Conn return readiness ? [readiness] : []; }); } + +const REGISTRY_CONNECTOR_PREFIX = "https://registry.pdpp.dev/connectors/"; +const TRAILING_SLASH = /\/$/; + +/** + * The bare key of a connector id: the registry-URL prefix and a trailing slash + * removed. It repeats the rule of `canonicalConnectorKey` in + * `connection-modality.ts`, which this module cannot import: that module reaches + * the shared setup planner, and the staged-console probe must not find the + * owner-token client in the same bundle module as the connector package + * (see `connector-install-client.import-graph.test.ts`). A test pins the two + * copies to the same answers. + */ +export function readinessConnectorKey(connectorId: string): string { + const trimmed = connectorId.trim(); + return trimmed.startsWith(REGISTRY_CONNECTOR_PREFIX) + ? trimmed.slice(REGISTRY_CONNECTOR_PREFIX.length).replace(TRAILING_SLASH, "") + : trimmed; +} + +/** The verdict for one connector from a response that may hold many, chosen by key and never by position. */ +export function selectConnectorReadiness( + entries: readonly ConnectorReadiness[], + connectorId: string +): ConnectorReadiness | null { + const requestedKey = readinessConnectorKey(connectorId); + return entries.find((entry) => readinessConnectorKey(entry.connectorKey) === requestedKey) ?? null; +}