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..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 @@ -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,6 +87,7 @@ 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/; @@ -131,19 +132,87 @@ 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); +}); + +// 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); + 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, /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); }); // Installing a package registers a connector but cannot create a setup path: @@ -155,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 () => { @@ -177,6 +245,51 @@ 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\./); + assert.doesNotMatch(needsRepair, /Digest mismatch/, "the server's diagnostic detail is not shown"); + 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)/components/source-setup-catalog.tsx b/apps/console/src/app/(console)/components/source-setup-catalog.tsx index ad54d5243..c751780d5 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 { @@ -466,6 +464,7 @@ function SourceSetupCard({ installLifecycle: ConnectorInstallLifecycle | null; }) { const status = sourceSetupStatus(entry); + const readinessNote = readinessBlockNote(entry.readiness); const { installModel, noSetupPath, @@ -509,11 +508,12 @@ function SourceSetupCard({

Package

- {entry.isKnownScaffold ? ( - - This connector's collection code is not built yet. + {readinessNote ? ( + + {readinessNote} - ) : 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..bdf643057 --- /dev/null +++ b/apps/console/src/app/(console)/lib/browser-connect-readiness.ts @@ -0,0 +1,35 @@ +// 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. 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 (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 : deferToOlderServer, + canAddAccount: readiness ? readiness.action === "connect" : deferToOlderServer, + }; +} 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-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.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 aecf9f380..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,6 +20,11 @@ import { parseConnectorLocalSourcesResponse, parseConnectorInstallStatusResponse, } from "./connector-install-contract.ts"; +import { + type ConnectorReadiness, + parseConnectorReadinessResponse, + selectConnectorReadiness, +} from "./connector-readiness-contract.ts"; import { describeErrorText } from "./describe-error.ts"; import { isConnectorInstallRouteUnavailableError, @@ -105,6 +110,18 @@ export async function listConnectorLocalSources(): Promise { + const payload = await connectorInstallFetch( + `/v1/owner/connector-install/readiness?connector_id=${encodeURIComponent(connectorId)}` + ); + return selectConnectorReadiness(parseConnectorReadinessResponse(payload), connectorId); +} + /** * 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..7c9f7c8e3 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.test.ts @@ -0,0 +1,178 @@ +// 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 { 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"; + +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("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("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); +}); + +/** 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 = 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(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"), + readiness("unavailable", "not_browser_bound"), + ]) { + assert.equal(readinessBlockNote(quiet), null, quiet.reason); + } + 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 new file mode 100644 index 000000000..a3f99982c --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-contract.ts @@ -0,0 +1,113 @@ +// 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", + "not_implemented", + "host_requirement_unmet", +] as const; +export type ConnectorReadinessReason = (typeof READINESS_REASONS)[number]; + +export interface ConnectorReadiness { + readonly action: ConnectorReadinessAction; + readonly browserBound: boolean; + readonly connectorKey: string; + /** Server diagnostic for repair, configure and retry. Null otherwise. The console does not render it. */ + 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] : []; + }); +} + +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; +} 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..8e2141ac9 --- /dev/null +++ b/apps/console/src/app/(console)/lib/connector-readiness-copy.ts @@ -0,0 +1,34 @@ +// 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. The text is fixed per reason: the + * server's `detail` is a diagnostic that the console does not render. + */ +export function readinessBlockNote(readiness: ConnectorReadiness | null | undefined): string | null { + if (!readiness) { + return null; + } + switch (readiness.reason) { + case "artifact_invalid": + 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."; + case "inspection_unavailable": + 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": + case "not_installed": + case "runnable": + return null; + } +} 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 ae15dd33b..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 @@ -1,30 +1,26 @@ // 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) => { + if (error instanceof ResourceServerHttpError && error.status === 404) { + serverPredatesReadiness = true; + return null; + } + throw error; + }); + 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..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,40 +184,48 @@ 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. 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 { + 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 === undefined; +} + +/** + * 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. */ 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 +417,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 +494,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 +598,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 +613,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", diff --git a/reference-implementation/runtime/scheduler-readiness.ts b/reference-implementation/runtime/scheduler-readiness.ts index 311b1eb93..d5dc7088b 100644 --- a/reference-implementation/runtime/scheduler-readiness.ts +++ b/reference-implementation/runtime/scheduler-readiness.ts @@ -205,8 +205,11 @@ 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); +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. if (!(await runDetectCommand(tool))) { @@ -214,17 +217,40 @@ export async function defaultReadinessChecker(schedule: ConnectorSchedule): Prom } } - if (requiredBindingEnabled(schedule.manifest, "browser") && !browserSurfaceConfigured()) { + if (options.unattendedBrowser && 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 }; } + +/** + * 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/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..d1f665e99 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,60 @@ export async function listVerifiedActiveConnectors(store: ConnectorInstallStore) return { invalid, verified }; } +/** + * 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" } + | { + 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 +818,9 @@ export interface ConnectorInstallService { catalog: () => Promise; 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; @@ -1155,6 +1213,8 @@ 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), + 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 new file mode 100644 index 000000000..f2a07b805 --- /dev/null +++ b/reference-implementation/server/connector-readiness.ts @@ -0,0 +1,393 @@ +// 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, + * 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. + * 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 { checkAttendedEnrollmentHostReadiness } from "../runtime/scheduler-readiness.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]; + +export const READINESS_REASONS = [ + "runnable", + "not_installed", + "not_published", + "catalog_unavailable", + "artifact_invalid", + "inspection_unavailable", + "not_browser_bound", + "not_implemented", + "host_requirement_unmet", +] as const; +export type ReadinessReason = (typeof READINESS_REASONS)[number]; + +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; + /** + * 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; +} + +/** + * 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 { + 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: 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": + 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, 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 + * 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. + */ +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 (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" }); + } + 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", "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 "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": + return "it can run"; + } +} + +export interface BrowserEnrollmentReadinessResolver { + /** + * 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; +} + +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 { + // 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; + /** + * 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 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 ?? checkAttendedEnrollmentHostReadiness; + const now = deps.now ?? Date.now; + const catalogTimeoutMs = deps.catalogTimeoutMs ?? CATALOG_READ_TIMEOUT_MS; + + // 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; + } + 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) && !declaresNoCollection(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()) + : { browserBound: null, fact: "not_consulted" as const }; + return projectBrowserEnrollmentReadiness({ + artifact, + catalog: catalog.fact, + catalogBrowserBound: catalog.browserBound, + connectorKey, + hintBrowserBound: isBrowserBound(hint ?? null), + host, + }); + }; + + // 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 ??= withTimeout( + deps.installService.catalog().catch(() => null), + catalogTimeoutMs + ); + return pending; + }; + }; + + return { + resolve: (connectorKey, registeredManifest) => + resolveWith(connectorKey, catalogReader(), false, registeredManifest), + resolveMany: (connectorKeys) => { + const readCatalog = catalogReader(); + return Promise.all(connectorKeys.map((connectorKey) => resolveWith(connectorKey, readCatalog, true))); + }, + }; +} 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/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/server/routes/owner-connector-install.ts b/reference-implementation/server/routes/owner-connector-install.ts index 8a3475788..3634fae8a 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, toReadinessWire } 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,33 @@ function projectLocalSource(record: LocalConnectorSourceRecord): Record (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.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, + ...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 +121,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 +275,32 @@ 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"); + 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)).map(toReadinessWire), + 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..e9aa46b5e 100644 --- a/reference-implementation/server/routes/owner-connector-templates.ts +++ b/reference-implementation/server/routes/owner-connector-templates.ts @@ -13,11 +13,15 @@ // supported. Interactive browser setup remains owner-mediated in Console. import { - browserEnrollmentSupport, buildConnectionSetupPlan, - isKnownScaffoldConnector, + classifyConnectorIntentModality, staticSecretCredentialCaptureFromManifest, } from "../connection-setup-plan.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"; @@ -93,6 +97,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 +198,13 @@ export function isSupportedOwnerActionPlan(plan: ReturnType, - manifest: ConnectorManifestLike + browserRunnable: boolean ): boolean { if (plan.connectorModality !== "browser_bound") { return false; @@ -207,31 +213,44 @@ 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( +function isOwnerActionablePlan(plan: ReturnType, browserRunnable: boolean): boolean { + 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 + manifest: ConnectorManifestLike, + browserRunnable: boolean ): boolean { - return isSupportedOwnerActionPlan(plan) || isOwnerSessionBrowserActionPlan(plan, manifest); + return ( + plan.catalogDisposition === "static_secret_connect" && + plan.setupModality === "static_secret" && + staticSecretCredentialCaptureFromManifest(manifest) !== null && + (plan.connectorModality !== "browser_bound" || 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" && - staticSecretCredentialCaptureFromManifest(manifest) !== null, + hasUatStaticSecretSetup(plan, manifest, browserRunnable), ].includes(true); if (basePlan) { @@ -244,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); } } @@ -256,6 +271,7 @@ function isUatExposablePlan( } function buildTemplateSupportedActions(args: { + browserRunnable: boolean; manifest: ConnectorManifestLike; plan: ReturnType; resource: string; @@ -276,7 +292,7 @@ function buildTemplateSupportedActions(args: { }, ]; } - if (isActionable && isOwnerSessionBrowserActionPlan(args.plan, args.manifest)) { + if (isActionable && isOwnerSessionBrowserActionPlan(args.plan, args.browserRunnable)) { return [ { family: "initiate_connection", @@ -300,13 +316,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 +346,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 +374,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 +385,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 ? toReadinessWire(readiness) : null, 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 +428,19 @@ 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 +476,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 +484,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..4cbf7285c 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, readinessReasonMessage } 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, manifest); + 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}): ${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 e6e9ce8c4..a0f47d8c8 100644 --- a/reference-implementation/test/browser-enrollment-shell-route.test.ts +++ b/reference-implementation/test/browser-enrollment-shell-route.test.ts @@ -11,10 +11,13 @@ import { expiredEnrollmentShellIds, retireExpiredBrowserEnrollmentShells, } from "../server/browser-enrollment-shell-retirement.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 { 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) @@ -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,104 @@ 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 lacks a tool the connector declares (409)", async () => { + await withServer(async ({ asUrl }) => { + 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 }, + 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/); + assert.doesNotMatch(asString(error.message), /fixture-tool|pdpp-tool-that-is-not-installed/, "detail is a diagnostic"); + }); +}); + +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/); }); }); 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..a4e6e6648 --- /dev/null +++ b/reference-implementation/test/connector-readiness-routes.test.ts @@ -0,0 +1,269 @@ +// 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 { 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)}`; + + +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 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, "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, 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); + }); +}); + +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..cd5f78fbf --- /dev/null +++ b/reference-implementation/test/connector-readiness.test.ts @@ -0,0 +1,505 @@ +// 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, + type ConnectorInstallService, + createConnectorInstallService, + createFileConnectorInstallStore, +} from "../server/connector-install/index.ts"; +import { createFileLocalConnectorSourceStore } from "../server/connector-install/local-source.ts"; +import { + type BrowserEnrollmentReadiness, + createBrowserEnrollmentReadinessResolver, + projectBrowserEnrollmentReadiness, + READINESS_ACTIONS, + READINESS_REASONS, +} 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 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, "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)]); + 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); + // 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")]); +}); + +// ─── 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: it names the connector browser-bound but is never runnable", () => { + const projected = projectBrowserEnrollmentReadiness({ + artifact: { status: "none" }, + catalog: "not_offered", + catalogBrowserBound: null, + connectorKey: "acme-shop", + hintBrowserBound: true, + host: null, + }); + assert.equal(projected.action, "unavailable"); + 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. + // + // 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]); +}); + +// ─── 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)); + } +}); 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..dd6fa7b82 100644 --- a/reference-implementation/test/owner-connector-templates.test.ts +++ b/reference-implementation/test/owner-connector-templates.test.ts @@ -14,11 +14,13 @@ 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 { 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"; +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 +76,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 +102,9 @@ async function withServer( await fn({ asUrl, rsUrl }); } finally { await closeServer(server); + restoreHost(); + fixtures?.close(); + fixtures = null; } } @@ -191,15 +205,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 +273,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 +353,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 +793,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 +907,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")); @@ -961,3 +983,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; + } + } + } +}); 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 () => { 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"),