diff --git a/.env.example b/.env.example index 6e39804570..d5f8163671 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,7 @@ DATABASE_URL=postgres://localhost:5432/workbench -# Public origin used to build OAuth callback URLs. Set it behind a proxy. +# Hub origin the web dev server proxies /api to. BASE_URL=http://localhost:3000 # PORT=3000 @@ -31,13 +31,6 @@ PRINCIPAL_KEY_ENCRYPTION_KEY=111111111111111111111111111111111111111111111111111 # Ollama origin also lets `bun run dev` mount the memory plane against it. # OLLAMA_BASE_URL=http://localhost:11434 -# OAuth connectors. Callback: /api/tenants//connections/oauth//callback -# GITHUB_APP_CLIENT_ID= -# GITHUB_APP_CLIENT_SECRET= -# GMAIL_CLIENT_ID= -# GMAIL_CLIENT_SECRET= -# HUGGINGFACE_OAUTH_CLIENT_ID= - # Memory plane (@corbits/memory). Run `bun run setup:memory` to fill these. # EMBED_BASE_URL=http://localhost:11434 # EMBED_MODEL=nomic-embed-text diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 70937d2d46..7bdfac8f98 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,9 +12,10 @@ handful of domain packages built on top. deploy/run) plus one composition block that adds Corbits libraries — `@corbits/mailbox` (chat), `@corbits/memory` (recall), `@corbits/artifacts` (files), `@corbits/cron` (scheduling) — and the remaining in-repo - packages: `connections` (credential catalog and OAuth), `webhook-triggers` - (inbound webhook ingress), `workflows` (author routes), and - `agent-directory`. + packages: `webhook-triggers` (inbound webhook ingress), `workflows` + (author routes), and `agent-directory`. Credentials are stored through + Interchange's own stock credential routes; nothing in this repo wraps + or replaces them. - **`apps/sidecar`** — a byte-for-byte copy of Interchange's own sidecar. It executes workflow runs and agent turns on the hub's behalf; Workbench makes no local changes to it. @@ -47,8 +48,8 @@ stock workflow-deploy route. `tools/*` and `skills/*` are ordinary ## Data -Custom tables — `connections`, `webhook-triggers`, `agent-directory`, and -so on — live on their own Postgres schema, each with foreign keys back to +Custom tables — `webhook-triggers`, `agent-directory`, and so on — live +on their own Postgres schema, each with foreign keys back to Interchange's `tenant`/`principal` tables in `public`. See [docs/package-migrations.md](docs/package-migrations.md) for how a package's own migrations are written and applied. Anything Interchange diff --git a/agents/myra/package.json b/agents/myra/package.json index b1c5132847..276c047844 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -31,8 +31,6 @@ }, "devDependencies": { "@corbits/agent-directory-tools": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/connections-tools": "workspace:*", "@corbits/interaction-tools": "workspace:*", "@types/bun": "catalog:", "typescript": "catalog:" diff --git a/agents/myra/src/system-prompt.ts b/agents/myra/src/system-prompt.ts index e8a6fb955a..1afcbb6530 100644 --- a/agents/myra/src/system-prompt.ts +++ b/agents/myra/src/system-prompt.ts @@ -21,11 +21,10 @@ export const ASSISTANT_STEP_ID = "assistant"; * `./index.ts` instead. `@corbits/capability-tools` lets Myra * self-service a missing tool, skill, or model; the manager-tools * bundles give Myra real workbench-management capability — a specialist - * agent she can create (each gets their own chat), connection - * visibility, and skill capture — each a thin wrapper over an existing - * platform primitive (see each package's own file-header comment for - * which one). `@corbits/mcp-tools` and `@corbits/interaction-tools` - * expose tenant-connected MCP servers and the ask-user card; + * agent she can create (each gets their own chat) and skill capture — + * each a thin wrapper over an existing platform primitive (see each + * package's own file-header comment for which one). + * `@corbits/interaction-tools` gives her the ask-user card; * `@corbits/manus-tools` is pinned so Manus tools exist when the tenant * has connected Manus (launch folds the binding only then — the pin * itself does not require a credential). `@corbits/access-tools` gives @@ -38,10 +37,8 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory", version: "0.1.2" }, { name: "@corbits/capability-tools", version: "0.0.7" }, { name: "@corbits/agent-directory-tools", version: "0.0.7" }, - { name: "@corbits/connections-tools", version: "0.0.12" }, { name: "@corbits/catalog-tools", version: "0.1.1" }, { name: "@corbits/skills-tools", version: "0.0.8" }, - { name: "@corbits/mcp-tools", version: "0.0.12" }, { name: "@corbits/interaction-tools", version: "0.0.11" }, { name: "@corbits/manus-tools", version: "0.0.12" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.8" }, diff --git a/agents/myra/test/manager-tools-scenario.test.ts b/agents/myra/test/manager-tools-scenario.test.ts deleted file mode 100644 index 0fc7c6fc85..0000000000 --- a/agents/myra/test/manager-tools-scenario.test.ts +++ /dev/null @@ -1,318 +0,0 @@ -// A scripted playback of the owner's canonical GTM scenario, exercised directly against the manager-tools -// bundles' `run()` with a single fake `fetch` standing in for every -// workflow-run-authenticated route those bundles call. This proves the -// CONTRACT across packages — a tool call's request shape matches what -// each route expects, and a route's response shape matches what each -// client parses — end to end through real bundle code, with no model -// or hub process involved. -// -// What this test does NOT prove: that a live model, given the assistant -// system prompt, actually chooses this tool sequence on its own. That is -// an [Intx/repo gap] this suite cannot close — it requires a live -// inference call (or at minimum a recorded transcript against a real -// model), which is out of scope for a unit suite. What IS proven here, -// honestly: every tool call this scenario needs is wired, its inputs -// are accepted by the real route contracts, and outputs from one step -// (a newly created agent's id) flow correctly into the next step's -// input (that agent is minted its own 1:1 chat) — the exact mechanical -// chain a model would need to walk. -// -// Scenario played back (owner's script) against three connectors that -// are registered (Exa, Granola, Linear). A connector name that is -// neither a fixed `CONNECTOR_REGISTRY` entry nor a curated MCP preset -// is separately asserted to be honestly rejected (fail-closed against -// the real registry, never a fabricated connector). -// 1. Myra is asked to set up a GTM sales motion. -// 2. She checks which connections exist; none of Exa/Granola/Linear -// are connected, so she hands over connect links for each. -// 3. Once the human connects them (simulated: the fake connections -// state flips to connected), she creates two specialist agents — -// a call-notes extractor and a weekly-analytics agent — minting -// each specialist its own 1:1 chat (never inviting into Myra's). -import { expect, test } from "bun:test"; -import type { ToolCall } from "@intx/types/runtime"; - -import { - connectionsTools, - LIST_CONNECTIONS_TOOL, - REQUEST_CONNECTION_TOOL, - type WorkflowConnectionEnv, -} from "@corbits/connections-tools"; -// `@corbits/connections-tools` carries no connector set of its own (it -// takes one through its env, so any Interchange-backed hub can install -// it with its own connector lineup) — this scenario plays back against -// Workbench's actual shipped set, so it wires in the real thing. -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; -import { - agentDirectoryTools, - CREATE_AGENT_TOOL, - type WorkflowAgentDirectoryEnv, -} from "@corbits/agent-directory-tools"; - -const HUB = "https://hub.example.com"; - -function call(id: string, name: string, args: Record): ToolCall { - return { id, name, arguments: args }; -} - -/** Minimal in-memory hub double: tracks which connectors are connected - * and which agent definitions have been created, and routes every - * request the two bundles under test can issue to the exact response - * shape their own clients parse. Any request this fake doesn't - * recognize fails the test loudly (thrown 404), rather than silently - * returning something a real hub never would. */ -function createFakeHub() { - const connected = new Set(); - const createdDefinitions: { id: string; name: string }[] = []; - const mintedDefinitionIds: string[] = []; - const invitedDefinitionIds: string[] = []; - const postedCards: { - connectorId: string; - displayName: string; - reason: string; - }[] = []; - - // Mirrors the real `CONNECTOR_REGISTRY` entries for these three ids - // (`packages/connections/src/registry.ts`) closely enough for this - // route double; `request_connection` itself validates against the - // REAL registry, not this list, so the fail-closed assertion below is - // still testing real code. - const registry = [ - { id: "exa", displayName: "Exa", docsUrl: "https://exa.ai/docs" }, - { id: "granola", displayName: "Granola", docsUrl: "https://granola.ai" }, - { id: "linear", displayName: "Linear", docsUrl: "https://linear.app/docs" }, - ]; - - const fetchImpl = (async (input: string | Request | URL, init?: RequestInit) => { - const url = new URL(String(input)); - const method = init?.method ?? "GET"; - const body = - typeof init?.body === "string" - ? (JSON.parse(init.body) as Record) - : undefined; - - if (url.pathname.endsWith("/providers") && method === "GET") { - return Response.json({ - data: registry.map((entry) => ({ - id: `prv_${entry.id}`, - name: entry.id, - })), - nextCursor: null, - }); - } - - if (url.pathname.endsWith("/credentials") && method === "GET") { - return Response.json({ - data: [...connected].map((id) => ({ - id: `crd_${id}`, - providerId: `prv_${id}`, - status: "active", - })), - nextCursor: null, - }); - } - - if (url.pathname === "/api/workflow-agent-directory/definitions" && method === "POST" && body) { - const id = `def_${String(createdDefinitions.length + 1)}`; - createdDefinitions.push({ id, name: body["name"] as string }); - return Response.json( - { - id, - name: body["name"], - description: null, - currentVersion: "1", - status: "deployed", - skills: [], - modelNote: null, - }, - { status: 201 }, - ); - } - - if (url.pathname === "/api/workflow-chat/participants/messages" && method === "POST" && body) { - // The connect-service card post `request_connection` makes into - // the caller's own room. Recorded so the scenario can assert the - // card itself, not just the tool's advice text. - const parts = body["parts"] as { - kind: string; - block: { type: string; data: Record }; - }[]; - const block = parts[0]?.block; - if (parts[0]?.kind !== "block" || block?.type !== "connect-service") { - throw new Error("fake hub: expected one connect-service block part"); - } - postedCards.push({ - connectorId: block.data["connectorId"] as string, - displayName: block.data["displayName"] as string, - reason: block.data["reason"] as string, - }); - return Response.json( - { - id: `msg_${String(postedCards.length)}`, - createdAt: "2026-08-16T00:00:00.000Z", - }, - { status: 201 }, - ); - } - - if (url.pathname === "/api/workflow-chat/participants/mint-dm" && method === "POST" && body) { - const definitionId = body["definitionId"] as string; - mintedDefinitionIds.push(definitionId); - return Response.json( - { - workbenchId: `wb_${definitionId}`, - address: `${definitionId}@workflow`, - definitionId, - handle: definitionId, - }, - { status: 201 }, - ); - } - - if (url.pathname === "/api/workflow-chat/participants/invite" && method === "POST" && body) { - const definitionId = body["definitionId"] as string; - invitedDefinitionIds.push(definitionId); - return Response.json( - { - address: `${definitionId}@workflow`, - definitionId, - handle: definitionId, - }, - { status: 201 }, - ); - } - - throw new Error(`fake hub: unhandled ${method} ${url.pathname}`); - }) as unknown as typeof fetch; - - return { - fetchImpl, - connected, - createdDefinitions, - invitedDefinitionIds, - mintedDefinitionIds, - postedCards, - }; -} - -test("GTM scenario: connect three services, then create two specialist agents", async () => { - const hub = createFakeHub(); - - // None of these two bundles' `WorkflowXEnv` types expose a - // `fetchImpl` test seam (only their `client.ts`'s own config type - // does, never threaded from `env`) — the established pattern this - // repo's other tool-bundle tests use instead (see - // `tools/capability/src/tool.test.ts`) is to monkey-patch - // `globalThis.fetch` for the call, restored in a `finally`. - const originalFetch = globalThis.fetch; - globalThis.fetch = hub.fetchImpl; - try { - await runScenario(hub); - } finally { - globalThis.fetch = originalFetch; - } -}); - -async function runScenario(hub: ReturnType): Promise { - const connectionsEnv: WorkflowConnectionEnv = { - hubConnectionsUrl: HUB, - tenantId: "ten_1", - sidecarToken: "sc-token", - address: "run_myra@workflow", - connectorRegistry: CONNECTOR_REGISTRY, - mcpPresets: MCP_PRESETS, - } as unknown as WorkflowConnectionEnv; - const agentDirectoryEnv: WorkflowAgentDirectoryEnv = { - hubAgentDirectoryUrl: HUB, - hubChatUrl: HUB, - sidecarToken: "sc-token", - address: "run_myra@workflow", - } as unknown as WorkflowAgentDirectoryEnv; - - // Step 2: Myra checks what's connected — nothing is, yet. - const connectionsBundle = connectionsTools(connectionsEnv); - const initialList = await connectionsBundle.run( - call("c1", LIST_CONNECTIONS_TOOL, {}), - new AbortController().signal, - ); - expect(initialList.isError).toBe(false); - expect(String(initialList.content)).toContain("Exa"); - expect(String(initialList.content)).toContain("Connected: none."); - - // A connector name that is neither a fixed `CONNECTOR_REGISTRY` - // entry, a curated MCP preset, nor a connected MCP server makes - // `request_connection` tell the agent to keep helping with what it - // can do — never "go add a server and report back". - const unknown = await connectionsBundle.run( - call("req_unknown", REQUEST_CONNECTION_TOOL, { connector: "acmecrm" }), - new AbortController().signal, - ); - expect(unknown.isError).toBe(false); - expect(String(unknown.content)).toContain("acmecrm"); - expect(String(unknown.content)).not.toContain("name and URL"); - expect(hub.postedCards).toHaveLength(0); - - // She puts a connect card in the room for each of the three (real) - // services — the card is what the human clicks; the tool - // result only tells Myra to keep helping in the meantime. - for (const connector of ["exa", "granola", "linear"]) { - const result = await connectionsBundle.run( - call(`req_${connector}`, REQUEST_CONNECTION_TOOL, { connector }), - new AbortController().signal, - ); - expect(result.isError).toBe(false); - expect(String(result.content)).toMatch(/card/i); - expect(String(result.content)).toMatch(/keep helping/i); - } - expect(hub.postedCards.map((card) => card.connectorId)).toEqual(["exa", "granola", "linear"]); - expect(hub.postedCards.map((card) => card.displayName)).toEqual(["Exa", "Granola", "Linear"]); - for (const card of hub.postedCards) { - expect(card.reason.length).toBeGreaterThan(0); - } - - // Human connects all three (simulated). - hub.connected.add("exa"); - hub.connected.add("granola"); - hub.connected.add("linear"); - - const afterConnect = await connectionsBundle.run( - call("c2", LIST_CONNECTIONS_TOOL, {}), - new AbortController().signal, - ); - expect(String(afterConnect.content)).toContain( - "GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble, Canva.", - ); - expect(String(afterConnect.content)).toContain("Connected: Granola, Exa, Linear."); - - // Step 3: Myra creates the two specialist agents she needs, minting - // each its own 1:1 chat (the tool's default). - const agentDirectoryBundle = agentDirectoryTools(agentDirectoryEnv); - const callNotesAgent = await agentDirectoryBundle.run( - call("a1", CREATE_AGENT_TOOL, { - name: "Call Notes Extractor", - systemPrompt: - "Extract structured notes and action items from Granola call " + - "transcripts and summarize them for the team.", - }), - new AbortController().signal, - ); - expect(callNotesAgent.isError).toBe(false); - const analyticsAgent = await agentDirectoryBundle.run( - call("a2", CREATE_AGENT_TOOL, { - name: "Weekly Analytics", - systemPrompt: - "Produce a weekly analytical rollup of sales-motion activity " + "for the team.", - }), - new AbortController().signal, - ); - expect(analyticsAgent.isError).toBe(false); - - expect(hub.createdDefinitions.map((d) => d.name)).toEqual([ - "Call Notes Extractor", - "Weekly Analytics", - ]); - // Both got their own chat (the default), never invited into Myra's. - expect(hub.mintedDefinitionIds).toEqual(hub.createdDefinitions.map((d) => d.id)); - expect(hub.invitedDefinitionIds).toEqual([]); -} diff --git a/apps/hub/package.json b/apps/hub/package.json index 24d5f83a74..a545a6b78c 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -18,14 +18,12 @@ "@corbits/artifact-ui": "workspace:*", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873", "@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider#a1f475f341261c05fa8dade0c82f1246c175b5b3", - "@corbits/connections": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/cron": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/hub-api-client": "workspace:*", "@corbits/inference-catalog": "workspace:*", "@corbits/mailbox": "github:corbitsdev/corbits-mailbox#e85b81e4a2e0cc318dd33a4f0f92337b6fac7f38", - "@corbits/mcp-tools": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", diff --git a/apps/hub/src/native-connector-registry.ts b/apps/hub/src/native-connector-registry.ts deleted file mode 100644 index 9141f1a1fa..0000000000 --- a/apps/hub/src/native-connector-registry.ts +++ /dev/null @@ -1,802 +0,0 @@ -// The hub's native connector registry: every connector and curated MCP -// preset this build ships, built from `@corbits/connections`' generic -// descriptor shape and route factories. `@corbits/connections` itself -// carries no default connector set, so the hub composes its own here and -// passes `CONNECTOR_REGISTRY` / `MCP_PRESETS` into the route factories that -// need one (connections, connections/oauth, workflow-connections, -// mcp-servers, the chat orchestrator's connector registry). -// -// Server-only, on purpose: the hub (bun) serves this to the settings -// surface over HTTP — it never ships in a browser bundle from here. -import { - OLLAMA_PLACEHOLDER_SECRET, - PROVIDER_TEST_CONFIG, - testProviderCredential, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; -import { - createConnectorRegistry, - type ConnectorDescriptor, - type ConnectorRegistry, -} from "@corbits/connections/registry"; -import type { McpPreset } from "@corbits/connections/mcp-presets"; -import { mcpPresetConnectorIds } from "@corbits/connections/mcp-presets"; -import { - exchangeCodeForToken, - HUGGINGFACE_AUTHORIZE_URL, - HUGGINGFACE_SCOPE, -} from "@corbits/connections/huggingface-connect"; -import { - exchangeCodeForGoogleToken, - GMAIL_SCOPE, - GOOGLE_AUTHORIZE_URL, -} from "@corbits/connections/gmail-connect"; - -// Decodes the ChatGPT account id out of a Codex id_token (a JWT payload -// claim), mirroring `@corbits/codex-provider`'s `accountIdFromIdToken` -// without routing the registry through that package's client surface. -// Undefined on any malformed input — the account id is a label, never a -// gate. (The legacy source kept this local so the registry could ship in -// the web bundle; the hub keeps the same local copy so the exchange result -// stays byte-identical through the cutover.) -function chatgptAccountIdFromIdToken(idToken: string): string | undefined { - const payload = idToken.split(".")[1]; - if (payload === undefined) return undefined; - let claims: Record; - try { - const base64 = payload.replaceAll("-", "+").replaceAll("_", "/"); - claims = JSON.parse(atob(base64.padEnd(Math.ceil(base64.length / 4) * 4, "="))) as Record< - string, - unknown - >; - } catch { - // report-error-ignore: a malformed id_token payload degrades to "no - // account id" — the account id is a label, never a gate, so there is - // nothing to report. - return undefined; - } - if (typeof claims.chatgpt_account_id === "string") { - return claims.chatgpt_account_id; - } - const auth: unknown = claims["https://api.openai.com/auth"]; - if (typeof auth === "object" && auth !== null && "chatgpt_account_id" in auth) { - const nested = auth.chatgpt_account_id; - if (typeof nested === "string") return nested; - } - return undefined; -} -// simple-icons ships one brand per named export, tree-shaken by any bundler -// that respects its `sideEffects: false` — importing only the brands this -// registry actually has a listing for pulls in only those icons' data, not -// the whole ~3000-brand package (CC0-1.0 licensed — see the package's own -// LICENSE — so redistributing these marks needs no separate clearance). -// Granola, ScrapeCreators, OpenAI, xAI, Groq, and Opencode Zen have no -// simple-icons listing, so those descriptors carry no `icon` and a caller -// renders their monochrome initial tile instead. Exa publishes -// its own mark in its official brand kit, used below. Google's mark -// here is Gemini's, not the generic Google "G" — the model brand a person -// actually recognizes from connecting an AI provider, matching -// models.dev's own convention for this row. -import { - siAnthropic, - siDeepseek, - siGithub, - siGmail, - siGooglegemini, - siHuggingface, - siLinear, - siMistralai, - siNotion, - siOllama, - siOpenrouter, - siPosthog, - siRailway, - siSentry, -} from "simple-icons"; -import { - CODEX_AUTHORIZE_EXTRA_PARAMS, - CODEX_AUTHORIZE_URL, - CODEX_CLIENT_ID, - CODEX_REDIRECT_URI, - CODEX_SCOPES, - CODEX_TOKEN_URL, -} from "@corbits/codex-provider"; -import { - XAI_AUTHORIZE_URL, - XAI_CLIENT_ID, - XAI_REDIRECT_URI, - XAI_SCOPES, - XAI_TOKEN_URL, -} from "@corbits/xai-provider"; -import { exchangeCodeForKey, OPENROUTER_AUTH_URL } from "@corbits/connections/openrouter-connect"; -import { - exchangeCodeForGithubToken, - GITHUB_AUTHORIZE_URL, -} from "@corbits/connections/github-connect"; -import { - testExaCredential, - testGitHubCredential, - testGranolaCredential, - testLinearCredential, - testManusCredential, - testScrapeCreatorsCredential, -} from "@corbits/connections/probes"; - -const INFERENCE_PROVIDER_DOCS_URL: Record = { - anthropic: "https://console.anthropic.com/settings/keys", - openai: "https://platform.openai.com/api-keys", - "google-genai": "https://aistudio.google.com/apikey", - xai: "https://console.x.ai", - "xai-oauth": "https://x.ai", - codex: "https://developers.openai.com/codex", - "opencode-zen": "https://opencode.ai/zen", - groq: "https://console.groq.com/keys", - deepseek: "https://platform.deepseek.com/api_keys", - mistral: "https://console.mistral.ai/api-keys", - openrouter: "https://openrouter.ai", - huggingface: "https://huggingface.co/settings/tokens", - ollama: "https://ollama.com", -}; - -// Ollama's onboarding URL field default — the local-machine origin -// Ollama listens on out of the box, before anyone points it at a -// tailscale-tunneled or otherwise remote instance. -const OLLAMA_DEFAULT_URL = "http://localhost:11434"; - -const EXA_ICON = { - path: "M150.5 14.1064C150.5 14.3356 150.421 14.5579 150.277 14.736L88.4766 91 150.277 167.264C150.421 167.442 150.5 167.664 150.5 167.894V181C150.5 181.552 150.052 182 149.5 182H1C0.44772 182 0 181.552 0 181V0.999995C0 0.44771 0.447715 0 1 0H149.5C150.052 0 150.5 0.447715 150.5 1V14.1064ZM30.4059 162.719H121.728L76.0664 106.326 30.4059 162.719ZM19.2949 100.261V145.787L56.1572 100.261H19.2949ZM19.2949 80.9801H55.5434L19.2949 36.2121V80.9801ZM76.0664 75.6731L121.728 19.281H30.4059L76.0664 75.6731Z", - hex: "0143D9", - viewBox: "0 0 151 182", -} as const; - -// Every inference provider with a simple-icons listing. OpenAI, xAI, Groq, -// and Opencode Zen have none as of simple-icons' current release; those -// four fall through to the monochrome initial tile every iconless -// descriptor already gets. -const INFERENCE_PROVIDER_ICONS: Partial< - Record -> = { - anthropic: { path: siAnthropic.path, hex: siAnthropic.hex }, - "google-genai": { path: siGooglegemini.path, hex: siGooglegemini.hex }, - deepseek: { path: siDeepseek.path, hex: siDeepseek.hex }, - mistral: { path: siMistralai.path, hex: siMistralai.hex }, - ollama: { path: siOllama.path, hex: siOllama.hex }, - openrouter: { path: siOpenrouter.path, hex: siOpenrouter.hex }, - huggingface: { path: siHuggingface.path, hex: siHuggingface.hex }, -}; - -function inferenceProviderDescriptors(): Record { - const entries: Record = {}; - for (const [id, config] of Object.entries(PROVIDER_TEST_CONFIG)) { - if (id === "openrouter" || id === "huggingface") continue; - if (id === "codex" || id === "xai-oauth") continue; - const providerId = id as SupportedCredentialProvider; - // Ollama collects a URL, not a secret — it has no auth layer at all - // (see `credential-test.ts`'s own `ollama` config entry). Every - // other connector's single form field is a real key, probed and - // stored as-is. - const icon = INFERENCE_PROVIDER_ICONS[providerId]; - entries[id] = - providerId === "ollama" - ? { - id, - displayName: config.displayName, - authKind: "api-key", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], - feedsTools: [], - credentialInputKind: "url", - credentialPlaceholder: OLLAMA_DEFAULT_URL, - probe: (baseURL) => - testProviderCredential({ - provider: providerId, - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURL, - }), - ...(icon !== undefined ? { icon } : {}), - } - : { - id, - displayName: config.displayName, - authKind: "api-key", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL[providerId], - feedsTools: [], - probe: (apiKey) => testProviderCredential({ provider: providerId, apiKey }), - ...(icon !== undefined ? { icon } : {}), - }; - } - entries["openrouter"] = { - id: "openrouter", - displayName: PROVIDER_TEST_CONFIG.openrouter.displayName, - authKind: "oauth-pkce", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL.openrouter, - feedsTools: [], - icon: { path: siOpenrouter.path, hex: siOpenrouter.hex }, - oauth: { - authorizeUrl: OPENROUTER_AUTH_URL, - usesPKCE: true, - // OpenRouter round-trips its state purely via the connect cookie; - // it never echoes `state` back as a callback query param. - echoesState: false, - deploysDefaultWorkflows: true, - buildAuthorizeUrl: ({ callbackUrl, codeChallenge }) => { - const url = new URL(OPENROUTER_AUTH_URL); - url.searchParams.set("callback_url", callbackUrl); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - return url; - }, - exchange: async ({ code, codeVerifier }) => { - const result = await exchangeCodeForKey({ - code, - codeVerifier: codeVerifier ?? "", - }); - return result.ok ? { ok: true, apiKey: result.key } : result; - }, - }, - }; - entries["huggingface"] = { - id: "huggingface", - displayName: PROVIDER_TEST_CONFIG.huggingface.displayName, - authKind: "oauth-pkce", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL.huggingface, - feedsTools: [], - icon: { path: siHuggingface.path, hex: siHuggingface.hex }, - oauth: { - authorizeUrl: HUGGINGFACE_AUTHORIZE_URL, - usesPKCE: true, - // HF echoes `state` back in the callback query — the belt-and- - // suspenders check `oauth-routes.ts` runs before consulting the - // state store at all. - echoesState: true, - deploysDefaultWorkflows: true, - // No client secret (a public app); the flow is disabled with a - // `not_configured` outcome when this key is absent from the env - // bag rather than round-tripping a doomed request. - clientId: (env) => env["huggingfaceClientId"], - buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { - const url = new URL(HUGGINGFACE_AUTHORIZE_URL); - if (clientId !== undefined) url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("scope", HUGGINGFACE_SCOPE); - url.searchParams.set("state", state); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - return url; - }, - exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { - if (clientId === undefined) { - return { - ok: false, - message: "huggingface connect is not configured", - }; - } - const result = await exchangeCodeForToken({ - code, - codeVerifier: codeVerifier ?? "", - redirectUri, - clientId, - }); - if (!result.ok) return result; - return result.expiresAt !== undefined - ? { - ok: true, - apiKey: result.accessToken, - expiresAt: result.expiresAt, - } - : { ok: true, apiKey: result.accessToken }; - }, - }, - }; - // The two loopback OAuth providers. Their authorization - // servers only accept the fixed `http://localhost:` redirect URI - // the provider's own CLI registers (`authKind: "oauth-loopback"`), so - // `buildAuthorizeUrl` ignores the hub callback URL the generic - // mechanics would pass and pins the provider's own redirect instead. - // Every endpoint, client id, and scope list come from the provider - // packages' own constants subpath (pure data — the exchange closures - // stay plain `fetch`), so the descriptor can never drift from the values - // the adapter's own refresh path uses. - entries["codex"] = { - id: "codex", - displayName: PROVIDER_TEST_CONFIG.codex.displayName, - authKind: "oauth-loopback", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL.codex, - feedsTools: [], - oauth: { - authorizeUrl: CODEX_AUTHORIZE_URL, - usesPKCE: true, - echoesState: true, - deploysDefaultWorkflows: true, - clientId: () => CODEX_CLIENT_ID, - buildAuthorizeUrl: ({ state, codeChallenge, clientId }) => { - const url = new URL(CODEX_AUTHORIZE_URL); - url.searchParams.set("client_id", clientId ?? CODEX_CLIENT_ID); - url.searchParams.set("redirect_uri", CODEX_REDIRECT_URI); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", CODEX_SCOPES.join(" ")); - url.searchParams.set("state", state); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - for (const [name, value] of Object.entries(CODEX_AUTHORIZE_EXTRA_PARAMS)) { - url.searchParams.set(name, value); - } - return url; - }, - exchange: async ({ code, codeVerifier }) => { - try { - const response = await fetch(CODEX_TOKEN_URL, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - signal: AbortSignal.timeout(15_000), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - code_verifier: codeVerifier ?? "", - client_id: CODEX_CLIENT_ID, - redirect_uri: CODEX_REDIRECT_URI, - }).toString(), - }); - if (!response.ok) { - return { - ok: false, - message: `Codex rejected the login (status ${response.status})`, - }; - } - const tokens = (await response.json()) as { - access_token?: unknown; - refresh_token?: unknown; - expires_in?: unknown; - id_token?: unknown; - }; - if (typeof tokens.access_token !== "string") { - return { ok: false, message: "Codex returned no access token" }; - } - const accountId = - typeof tokens.id_token === "string" - ? chatgptAccountIdFromIdToken(tokens.id_token) - : undefined; - return { - ok: true, - apiKey: tokens.access_token, - ...(typeof tokens.refresh_token === "string" - ? { refreshToken: tokens.refresh_token } - : {}), - ...(typeof tokens.expires_in === "number" - ? { - expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), - } - : {}), - ...(accountId !== undefined ? { accountId } : {}), - }; - } catch (cause) { - // report-error-ignore: a failed Codex exchange is surfaced to the - // caller in the returned message — the failure IS the result. - return { - ok: false, - message: `Codex token exchange failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }; - } - }, - }, - }; - entries["xai-oauth"] = { - id: "xai-oauth", - displayName: PROVIDER_TEST_CONFIG["xai-oauth"].displayName, - authKind: "oauth-loopback", - credentialPlugin: "http", - docsUrl: INFERENCE_PROVIDER_DOCS_URL["xai-oauth"], - feedsTools: [], - oauth: { - authorizeUrl: XAI_AUTHORIZE_URL, - usesPKCE: true, - echoesState: true, - deploysDefaultWorkflows: true, - clientId: () => XAI_CLIENT_ID, - buildAuthorizeUrl: ({ state, codeChallenge, clientId }) => { - const url = new URL(XAI_AUTHORIZE_URL); - url.searchParams.set("client_id", clientId ?? XAI_CLIENT_ID); - url.searchParams.set("redirect_uri", XAI_REDIRECT_URI); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", XAI_SCOPES.join(" ")); - url.searchParams.set("state", state); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - } - url.searchParams.set("code_challenge_method", "S256"); - return url; - }, - exchange: async ({ code, codeVerifier }) => { - try { - const response = await fetch(XAI_TOKEN_URL, { - method: "POST", - headers: { "content-type": "application/x-www-form-urlencoded" }, - signal: AbortSignal.timeout(15_000), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - code_verifier: codeVerifier ?? "", - client_id: XAI_CLIENT_ID, - redirect_uri: XAI_REDIRECT_URI, - }).toString(), - }); - if (!response.ok) { - return { - ok: false, - message: `xAI rejected the login (status ${response.status})`, - }; - } - const tokens = (await response.json()) as { - access_token?: unknown; - refresh_token?: unknown; - expires_in?: unknown; - }; - if (typeof tokens.access_token !== "string") { - return { ok: false, message: "xAI returned no access token" }; - } - return { - ok: true, - apiKey: tokens.access_token, - ...(typeof tokens.refresh_token === "string" - ? { refreshToken: tokens.refresh_token } - : {}), - ...(typeof tokens.expires_in === "number" - ? { - expiresAt: new Date(Date.now() + tokens.expires_in * 1000).toISOString(), - } - : {}), - }; - } catch (cause) { - // report-error-ignore: a failed xAI exchange is surfaced to the - // caller in the returned message — the failure IS the result. - return { - ok: false, - message: `xAI token exchange failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }; - } - }, - }, - }; - return entries; -} - -export const CONNECTOR_REGISTRY: ConnectorRegistry = createConnectorRegistry({ - ...inferenceProviderDescriptors(), - granola: { - id: "granola", - credentialPlugin: "http", - displayName: "Granola", - authKind: "api-key", - docsUrl: "https://www.granola.ai", - feedsTools: ["@corbits/granola-tools"], - probe: (apiKey) => testGranolaCredential(apiKey), - description: "Pull meeting notes and transcripts into your agents.", - }, - manus: { - id: "manus", - credentialPlugin: "http-x-manus-api-key", - displayName: "Manus", - authKind: "api-key", - docsUrl: "https://open.manus.ai/docs/v2/introduction", - feedsTools: ["@corbits/manus-tools"], - probe: (apiKey) => testManusCredential(apiKey), - description: "Have Manus run tasks and produce files — including slide decks.", - }, - exa: { - id: "exa", - // Exa authenticates via an `x-api-key` header, not `authorization` — - // the `http` (Bearer) plugin doesn't fit; see the `http-x-api-key` - // provider in `@corbits/credential-providers`. - credentialPlugin: "http-x-api-key", - displayName: "Exa", - authKind: "api-key", - docsUrl: "https://exa.ai", - feedsTools: ["@corbits/web-search-tools"], - probe: (apiKey) => testExaCredential(apiKey), - description: "Neural web search built for agents.", - icon: EXA_ICON, - }, - scrapecreators: { - id: "scrapecreators", - // ScrapeCreators, same as Exa, authenticates via `x-api-key`. - credentialPlugin: "http-x-api-key", - displayName: "ScrapeCreators", - authKind: "api-key", - docsUrl: "https://scrapecreators.com", - feedsTools: ["@corbits/reddit-tools"], - probe: (apiKey) => testScrapeCreatorsCredential(apiKey), - description: "Read Reddit threads and comments.", - }, - linear: { - id: "linear", - credentialPlugin: "http-raw-authorization", - displayName: "Linear", - authKind: "api-key", - docsUrl: "https://linear.app/settings/api", - feedsTools: ["@corbits/linear-tools"], - probe: (apiKey) => testLinearCredential(apiKey), - description: "Read and write issues, projects, and comments.", - icon: { path: siLinear.path, hex: siLinear.hex }, - }, - gmail: { - id: "gmail", - displayName: "Gmail", - // Pure hosted-app OAuth — there is no Gmail equivalent of a PAT - // to paste, so unlike `github` below this connector has no - // api-key arm: unconfigured (`GMAIL_CLIENT_ID`/`GMAIL_CLIENT_SECRET` - // unset) means the card renders "not configured", never a dead - // paste form. The exchanged access token expires in about an - // hour; the refresh token rides `OAuthExchangeResult.refreshToken` - // into the credential row's `refreshSecret` so a refresh sweep - // can keep the connection alive. - authKind: "oauth-code", - credentialPlugin: "http", - docsUrl: "https://developers.google.com/gmail/api", - feedsTools: [], - description: "Read, draft, and send email — sending waits for your ok.", - icon: { path: siGmail.path, hex: siGmail.hex }, - oauth: { - authorizeUrl: GOOGLE_AUTHORIZE_URL, - usesPKCE: true, - echoesState: true, - deploysDefaultWorkflows: false, - clientId: (env) => env["gmailClientId"], - clientSecret: (env) => env["gmailClientSecret"], - buildAuthorizeUrl: ({ callbackUrl, state, codeChallenge, clientId }) => { - const url = new URL(GOOGLE_AUTHORIZE_URL); - if (clientId !== undefined) url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("response_type", "code"); - url.searchParams.set("scope", GMAIL_SCOPE); - url.searchParams.set("state", state); - // `offline` + forced consent is what makes Google issue a - // refresh token at all — without both, a re-connect silently - // returns only another one-hour access token. - url.searchParams.set("access_type", "offline"); - url.searchParams.set("prompt", "consent"); - if (codeChallenge !== undefined) { - url.searchParams.set("code_challenge", codeChallenge); - url.searchParams.set("code_challenge_method", "S256"); - } - return url; - }, - exchange: async ({ code, codeVerifier, redirectUri, clientId, clientSecret }) => { - if (clientId === undefined || clientSecret === undefined) { - return { - ok: false, - message: "gmail connect is not configured", - }; - } - return exchangeCodeForGoogleToken({ - code, - ...(codeVerifier !== undefined ? { codeVerifier } : {}), - redirectUri, - clientId, - clientSecret, - }); - }, - }, - }, - github: { - id: "github", - // GitHub's REST API accepts a fine-grained PAT — or a GitHub OAuth - // App user token, which is the same Bearer shape — as a Bearer - // token. Absent entirely, github-tools degrades to a lower - // unauthenticated rate limit rather than "not connected" — see its - // tool.ts. `authKind` stays "api-key" (the PAT paste form is - // always available as a guaranteed fallback); the `oauth` - // config below is this connector's one exception to "oauth fields - // are oauth-pkce/oauth-code only" — a caller checks - // `GET /oauth-configured`'s `github` entry to decide whether to - // offer the one-click app connect ahead of the paste form, never - // the other way around, so an unconfigured deploy never dead-ends. - credentialPlugin: "http", - displayName: "GitHub", - authKind: "api-key", - docsUrl: "https://github.com/settings/tokens", - feedsTools: ["@corbits/github-tools"], - probe: (apiKey, opts) => testGitHubCredential(apiKey, undefined, opts?.baseUrl), - description: "Read repos, issues, and pull requests.", - icon: { path: siGithub.path, hex: siGithub.hex }, - oauth: { - authorizeUrl: GITHUB_AUTHORIZE_URL, - usesPKCE: false, - echoesState: true, - deploysDefaultWorkflows: false, - clientId: (env) => env["githubAppClientId"], - clientSecret: (env) => env["githubAppClientSecret"], - buildAuthorizeUrl: ({ callbackUrl, state, clientId }) => { - const url = new URL(GITHUB_AUTHORIZE_URL); - if (clientId !== undefined) url.searchParams.set("client_id", clientId); - url.searchParams.set("redirect_uri", callbackUrl); - url.searchParams.set("scope", "repo"); - url.searchParams.set("state", state); - return url; - }, - exchange: async ({ code, redirectUri, clientId, clientSecret }) => { - if (clientId === undefined || clientSecret === undefined) { - return { - ok: false, - message: "github app connect is not configured", - }; - } - const result = await exchangeCodeForGithubToken({ - code, - redirectUri, - clientId, - clientSecret, - }); - return result.ok ? { ok: true, apiKey: result.key } : result; - }, - }, - }, - "granola-webhook": { - id: "granola-webhook", - // No credential-provider plugin mediates this connector's secret — - // it is minted and encrypted entirely inside - // `@corbits/webhook-triggers` (see `management-routes.ts`), never - // stored as a Credential row. `credentialPlugin` still has to name - // something (`ConnectorDescriptor` requires it uniformly); `http` - // is inert here, kept only so registry-wide invariants (every - // non-bearer connector reads `http`) hold without a special case. - credentialPlugin: "http", - displayName: "Granola inbound webhook", - authKind: "webhook-secret", - docsUrl: "https://www.granola.ai", - // The direction is inverted from the `granola` api-key connector - // above: this issues a signed inbound address for Granola to call, - // feeding nothing — a routine's webhook trigger binding, not a - // tool package's credential handle. - feedsTools: [], - }, -}); - -/** - * Curated remote MCP servers that Workbench can connect without asking a - * person for a URL, API key, client id, or client secret. OAuth entries have - * been verified to advertise OAuth discovery, PKCE, and dynamic client - * registration; keyless entries can be probed and stored immediately; token - * entries accept a pasted access token as the bearer (GitHub's MCP server - * does OAuth only for clients pre-registered with GitHub — it offers no - * dynamic client registration — so its connect here is the token walk). - */ -export const MCP_PRESETS: readonly McpPreset[] = [ - { - slug: "granola", - displayName: "Granola", - description: "Search meeting notes, transcripts, and action items.", - url: "https://mcp.granola.ai/mcp", - connectionMode: "oauth", - docsUrl: "https://docs.granola.ai/help-center/sharing/integrations/mcp", - nativeConnectorId: "granola", - }, - { - slug: "exa", - displayName: "Exa", - description: "Search and research the live web.", - url: "https://mcp.exa.ai/mcp", - connectionMode: "keyless", - docsUrl: "https://docs.exa.ai/reference/exa-mcp", - nativeConnectorId: "exa", - }, - { - slug: "linear", - displayName: "Linear", - description: "Read and update issues, projects, and comments.", - url: "https://mcp.linear.app/mcp", - connectionMode: "oauth", - docsUrl: "https://linear.app/docs/mcp", - nativeConnectorId: "linear", - }, - { - // Deliberately NOT slug "github" and NOT nativeConnectorId "github": - // the native GitHub connector (PAT/OAuth-App feeding - // @corbits/github-tools) stays its own card, and preset lookups by - // "github" must keep resolving to it, never here. - slug: "github-mcp", - displayName: "GitHub MCP", - description: "Search code, work with issues and pull requests.", - url: "https://api.githubcopilot.com/mcp/", - connectionMode: "token", - docsUrl: "https://github.com/settings/tokens", - icon: { path: siGithub.path, hex: siGithub.hex }, - tokenSteps: [ - "Open github.com/settings/tokens and generate a new token.", - "Give it the repo scope — that lets agents read code, issues, and pull requests.", - "Paste it below. It's stored encrypted, only your agents use it, and you can disconnect any time.", - ], - }, - { - slug: "notion", - displayName: "Notion", - description: "Search and update pages, databases, and workspace content.", - url: "https://mcp.notion.com/mcp", - connectionMode: "oauth", - docsUrl: "https://developers.notion.com/guides/mcp/get-started-with-mcp", - icon: { path: siNotion.path, hex: siNotion.hex }, - }, - { - slug: "sentry", - displayName: "Sentry", - description: "Investigate errors, traces, releases, and projects.", - url: "https://mcp.sentry.dev/mcp", - connectionMode: "oauth", - docsUrl: "https://mcp.sentry.dev/", - icon: { path: siSentry.path, hex: siSentry.hex }, - }, - { - slug: "attio", - displayName: "Attio", - description: "Work with CRM records, lists, notes, and tasks.", - url: "https://mcp.attio.com/mcp", - connectionMode: "oauth", - docsUrl: "https://docs.attio.com/mcp/overview", - }, - { - slug: "railway", - displayName: "Railway", - description: "Inspect and manage projects, services, and deployments.", - url: "https://mcp.railway.com", - connectionMode: "oauth", - docsUrl: "https://docs.railway.com/ai/mcp-server", - icon: { path: siRailway.path, hex: siRailway.hex }, - }, - { - slug: "posthog", - displayName: "PostHog", - description: "Explore product analytics, errors, flags, and experiments.", - url: "https://mcp.posthog.com/mcp", - connectionMode: "oauth", - docsUrl: "https://posthog.com/docs/model-context-protocol", - icon: { path: siPosthog.path, hex: siPosthog.hex }, - }, - { - slug: "sumble", - displayName: "Sumble", - description: "Research accounts, people, technologies, and buying signals.", - url: "https://mcp.sumble.com/", - connectionMode: "oauth", - docsUrl: "https://sumble.com/guides/account-research", - }, - { - // No simple-icons listing for Canva — same gap as Granola - // and Sumble above, so this card falls back to the initial-letter - // tile rather than risk a hand-traced or doctored mark. - slug: "canva", - displayName: "Canva", - description: "Design decks, docs, and graphics.", - url: "https://mcp.canva.com/mcp", - connectionMode: "oauth", - docsUrl: "https://www.canva.dev/docs/mcp/", - oauthScopes: [ - "profile:read", - "design:meta:read", - "design:content:write", - "design:content:read", - "folder:read", - "folder:write", - "brandtemplate:content:read", - "brandtemplate:meta:read", - "brandtemplate:content:write", - "comment:write", - "comment:read", - "asset:read", - "asset:write", - "brandkit:read", - "help:answers:read", - "help:answers:write", - ], - }, -]; - -export const MCP_PRESET_CONNECTOR_IDS: readonly string[] = mcpPresetConnectorIds(MCP_PRESETS); diff --git a/apps/hub/src/server.ts b/apps/hub/src/server.ts index dc11e6180c..656175b684 100644 --- a/apps/hub/src/server.ts +++ b/apps/hub/src/server.ts @@ -93,18 +93,6 @@ import { artifactMatchesLibraryKindSegment, LIBRARY_KIND_SEGMENTS, } from "@corbits/artifact-ui/kind-filter"; -import { - createConnectionRoutes, - createMcpOAuthRoutes, - createMcpServerRoutes, - createOAuthConnectRoutes, - createTenantConnectCredential, - createWorkflowConnectionRoutes, - DEFAULT_RETURN_PATH_ALLOWLIST, - listMcpServerConnections, -} from "@corbits/connections"; -import { createProviderHealthStore } from "@corbits/connections/provider-health"; -import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; import type { DB } from "@intx/db"; import { sidecar, workflowRun } from "@intx/db/schema"; import path from "node:path"; @@ -118,7 +106,7 @@ const grantConditionRegistry: ConditionRegistry = { }; // The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated -// Corbits surface below takes structurally (connections, workflow-authoring, +// Corbits surface below takes structurally (workflow-authoring, // `@corbits/artifacts`' `mountWorkflowArtifacts`): a sidecar bearer token + // run address resolve to the tenant/principal/run it names. function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { @@ -676,80 +664,6 @@ export async function createHubServer({ }), ); - const hubUrl = process.env["BASE_URL"] ?? `http://localhost:${String(port)}`; - const providerHealthStore = createProviderHealthStore(); - app.route( - `${TENANT_PREFIX}/connections`, - createConnectionRoutes({ - hubUrl, - registry: CONNECTOR_REGISTRY, - requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), - log: (line) => log.info`${line}`, - oauthEnv: { - huggingfaceClientId: process.env["HUGGINGFACE_OAUTH_CLIENT_ID"], - githubAppClientId: process.env["GITHUB_APP_CLIENT_ID"], - githubAppClientSecret: process.env["GITHUB_APP_CLIENT_SECRET"], - gmailClientId: process.env["GMAIL_CLIENT_ID"], - gmailClientSecret: process.env["GMAIL_CLIENT_SECRET"], - }, - providerHealth: providerHealthStore, - probeBaseUrls: - process.env["GITHUB_API_BASE_URL"] !== undefined - ? { github: process.env["GITHUB_API_BASE_URL"] } - : {}, - }), - ); - app.route( - `${TENANT_PREFIX}/connections/oauth`, - createOAuthConnectRoutes({ - hubUrl, - log: (line) => log.info`${line}`, - credentialCipher, - registry: CONNECTOR_REGISTRY, - oauthEnv: { - huggingfaceClientId: process.env["HUGGINGFACE_OAUTH_CLIENT_ID"], - githubAppClientId: process.env["GITHUB_APP_CLIENT_ID"], - githubAppClientSecret: process.env["GITHUB_APP_CLIENT_SECRET"], - gmailClientId: process.env["GMAIL_CLIENT_ID"], - gmailClientSecret: process.env["GMAIL_CLIENT_SECRET"], - }, - connectCredential: createTenantConnectCredential({ - hubUrl, - log: (line) => log.info`${line}`, - registry: CONNECTOR_REGISTRY, - providerHealth: providerHealthStore, - }), - defaultReturnPath: "/settings/connections", - returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], - }), - ); - app.route( - `${TENANT_PREFIX}/mcp-servers`, - createMcpServerRoutes({ - hubUrl, - requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), - log: (line) => log.info`${line}`, - presets: MCP_PRESETS, - }), - ); - app.route( - `${TENANT_PREFIX}/mcp-servers/oauth`, - createMcpOAuthRoutes({ - hubUrl, - requireGrant: createRequireGrant({ grantStore, conditionRegistry: grantConditionRegistry }), - log: (line) => log.info`${line}`, - credentialCipher, - presets: MCP_PRESETS, - returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], - }), - ); - app.route( - "/api/workflow-connections", - createWorkflowConnectionRoutes({ - authenticator: createWorkflowRunAuthenticator({ db }), - listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), - }), - ); // --------------------------------------------------------------------- // End of Corbits mount block. // --------------------------------------------------------------------- diff --git a/apps/web/package.json b/apps/web/package.json index 56c7a9b8fe..8c84aa35da 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -20,7 +20,6 @@ "@corbits/agent-directory": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", - "@corbits/connections": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/myra": "workspace:*", "@corbits/react-ui": "github:corbitsdev/react-ui#3b122812a307ccb35be31386f7696020c5a84635", diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index e7279517ed..8e468ef1c1 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -19,7 +19,7 @@ import { useQuery } from "@tanstack/react-query"; import type { APIQuery } from "@/lib/api-query"; import { ApiQueryError, UnauthenticatedError, toAPIQuery } from "@/lib/api-query"; -import { isChatPickerModelName } from "@corbits/connections/model-capability"; +import { isChatPickerModelName } from "./settings/inference/model-capability"; import { parseErrorEnvelope } from "@corbits/error-sink"; import { tenantKeys } from "./query-client"; diff --git a/apps/web/src/app.css b/apps/web/src/app.css index dce92e08c3..be5470c235 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -303,84 +303,6 @@ select:disabled, flex-direction: column; } -/* the guided-fix banner, above the stage's own top bar — quiet - until there is something to act on, then a clear (not alarming) nudge - toward the fix. */ -/* Mount/unmount collapse: a grid-rows 0fr/1fr toggle so the - banner's entrance/exit eases the stage's height rather than snapping it - open or shut. `theme.css` (react-ui) already collapses every - transition duration under `prefers-reduced-motion`, so this needs no - media query of its own. */ -.provider-health-banner-collapse { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 220ms ease; -} - -.provider-health-banner-collapse.is-open { - grid-template-rows: 1fr; -} - -.provider-health-banner-collapse-inner { - overflow: hidden; - min-height: 0; -} - -.provider-health-banner { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 1rem; - border-bottom: 1px solid var(--border); - background: var(--card); - flex-shrink: 0; - opacity: 0; - transition: opacity 150ms ease; -} - -.provider-health-banner-collapse.is-open .provider-health-banner { - opacity: 1; -} - -.provider-health-banner-icon { - width: 1rem; - height: 1rem; - flex-shrink: 0; - color: var(--destructive); -} - -.provider-health-banner-text { - flex: 1; - min-width: 0; - font-size: 0.8125rem; - color: var(--card-foreground); -} - -/* The Plugins gallery's own "couldn't find that connection" notice: a deep - link from the shell banner that names a provider the gallery has no - card for. */ -.plugins-connect-notice { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 1rem; - margin-bottom: 0.75rem; - border: 1px solid var(--border); - background: var(--card); -} - -.plugins-connect-notice-icon { - width: 1rem; - height: 1rem; - flex-shrink: 0; - color: var(--destructive); -} - -.plugins-connect-notice-text { - font-size: 0.8125rem; - color: var(--card-foreground); -} - /* Column 2: SidebarPanel root; width overrides the library default (w-72). Mock sidebar geometry: pure ground, light right border. */ .shell-sidebar { diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index 29ab149f1f..da9d265d28 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -22,7 +22,6 @@ import { APP_ROUTES, LOGIN_PATH, matchesRoute, ONBOARDING_PATH } from "./routes" import type { SessionState, SessionUser } from "./session"; import { AppShell } from "./shell/app-shell"; import { ComposerInsertionProvider } from "./shell/composer-insertion"; -import { ProviderHealthProvider } from "./shell/provider-health-context"; import { ShellChromeProvider } from "./shell/shell-chrome-provider"; /** Any signed-out request for a path other than `/login` itself bounces @@ -89,17 +88,15 @@ function Shell({ - - - - - - {route === undefined ? : route.render(path, navigate)} - - - - - + + + + + {route === undefined ? : route.render(path, navigate)} + + + + diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index ffb2e8281d..59dd57a494 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -32,8 +32,7 @@ import { NAV_ROUTES } from "./routes"; import { ArtifactListPageSchema, useAPIQuery } from "./api"; import { isBenchMembership, useBench } from "./bench-context"; import { useCloseCanvas } from "./shell/canvas-availability"; -import { listMcpServers } from "@/tools"; -import { AGENTS_PATH_PREFIX, PLUGINS_PATH_PREFIX, SKILLS_PATH_PREFIX } from "./path-ids"; +import { AGENTS_PATH_PREFIX, SKILLS_PATH_PREFIX } from "./path-ids"; import { listScheduledWorkflows, runScheduledWorkflowNow, useTenantQuery } from "./routines-api"; import { listSkills } from "./skills-api"; import { tenantKeys } from "./query-client"; @@ -238,14 +237,6 @@ export function CommandPaletteProvider({ open && selectedTenantId !== null, () => listSkills(selectedTenantId ?? ""), ); - // Connected MCP servers for this bench. Until lands a real - // `/plugins/` page, selecting one opens the Plugins gallery - // rather than a "still being built" stub. - const mcpServersQuery = useTenantQuery( - tenantKeys.mcpServers(selectedTenantId ?? ""), - open && selectedTenantId !== null, - () => listMcpServers(selectedTenantId ?? ""), - ); const artifactsQuery = useAPIQuery( selectedTenantId === null || !open ? "" : `/api/tenants/${selectedTenantId}/artifacts`, ArtifactListPageSchema, @@ -375,18 +366,6 @@ export function CommandPaletteProvider({ [skillsQuery], ); - const pluginItems = useMemo( - () => - mcpServersQuery.kind === "ready" - ? mcpServersQuery.data.map((server) => ({ - id: `entity:plugins:${server.slug}`, - title: server.name, - subtitle: "Connected plugin", - })) - : [], - [mcpServersQuery], - ); - const libraryItems = useMemo( () => artifactsQuery.kind === "ready" @@ -420,7 +399,6 @@ export function CommandPaletteProvider({ { id: "pages", heading: "Pages", kind: "pages", items: pageItems }, { id: "routines", heading: "Routines", items: routineItems }, { id: "skills", heading: "Skills", items: skillItems }, - { id: "plugins", heading: "Plugins", items: pluginItems }, { id: "library", heading: "Files", items: libraryItems }, { id: "people", @@ -429,16 +407,7 @@ export function CommandPaletteProvider({ items: agentItems, }, ], - [ - actionItems, - workbenchItems, - pageItems, - routineItems, - skillItems, - pluginItems, - libraryItems, - agentItems, - ], + [actionItems, workbenchItems, pageItems, routineItems, skillItems, libraryItems, agentItems], ); const recentItems = useMemo( @@ -522,13 +491,6 @@ export function CommandPaletteProvider({ // A skill's name is its slug: the Skills API keys every route on it. navigate(detailPath(SKILLS_PATH_PREFIX, { slug: skillId, id: skillId })); pushRecent({ kind: "skills", id, title, subtitle: "Skill" }); - } else if (id.startsWith("entity:plugins:")) { - const slug = id.slice("entity:plugins:".length); - const title = pluginItems.find((item) => item.id === id)?.title ?? slug; - // No plugin detail page yet (parked). Land on the gallery - // instead of the removed stub. - navigate(PLUGINS_PATH_PREFIX); - pushRecent({ kind: "plugins", id, title, subtitle: "Plugin" }); } else if (id.startsWith("entity:library:")) { const artifactId = id.slice("entity:library:".length); const title = libraryItems.find((item) => item.id === id)?.title ?? "Files"; @@ -548,7 +510,6 @@ export function CommandPaletteProvider({ agentItems, routineItems, skillItems, - pluginItems, libraryItems, nextWorkbench, selectTenant, diff --git a/apps/web/src/connect-service-actions.ts b/apps/web/src/connect-service-actions.ts deleted file mode 100644 index deed7cea6d..0000000000 --- a/apps/web/src/connect-service-actions.ts +++ /dev/null @@ -1,174 +0,0 @@ -// Builds the `ConnectServiceActions` port `ChatWorkspace` (`@/chat`) -// calls for the generic in-room connect card — the room -// connect-github binding's shape (hub-zero T3, deleted with the -// workbench-scoped mount) generalized to every connector and -// MCP preset. The block's data carries only agent-authored framing; -// every live fact is resolved here: a curated preset reads the -// tenant's MCP preset listing (connected flag + oauth/keyless mode), a -// registry connector reads `oauth-configured` (one-click vs key-paste) -// and the tenant's provider rows (connected or not). -// -// OAuth connects navigate the whole page to the hosted start route and -// return to this room; the card re-reads its state on remount, and the -// hub's connect-settling hook posts the in-room resume message. A -// credential completed without leaving the room (another tab, the -// Plugins page) publishes `chat.settings`; ChatWorkspace parses that -// event and calls `notifySettingsChanged` so a mounted card flips -// without remounting. -import type { ConnectServiceActions, ConnectServiceQuery } from "@/chat"; -import { - completeConnectorCredential, - ConnectionsApiError, - fetchOAuthConfigured, - listProviders, - oauthStartHref, -} from "@/settings"; -import { connectMcpPreset, listMcpPresets, McpServersApiError } from "@/tools"; -import { mcpPresetBySlug, MCP_PRESETS } from "@corbits/connections/mcp-presets"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; - -function bareConnectorId(connectorId: string): string { - return connectorId.startsWith("mcp:") ? connectorId.slice("mcp:".length) : connectorId; -} - -export function createChatConnectServiceActions( - tenantId: string, - returnPath: string, -): ConnectServiceActions { - const listeners = new Map void>>(); - - function fanOut(connectorId: string, query: ConnectServiceQuery) { - for (const listener of listeners.get(connectorId) ?? []) listener(query); - } - - async function readState(connectorId: string): Promise { - const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(MCP_PRESETS, slug); - if (preset !== undefined) { - const presets = await listMcpPresets(tenantId); - const listed = presets.find((entry) => entry.slug === preset.slug); - if (listed?.connected === true) return { kind: "connected" }; - if (preset.connectionMode === "keyless") { - return { kind: "disconnected", affordance: "keyless" }; - } - if (preset.connectionMode === "token") { - return { - kind: "disconnected", - affordance: "api-key", - docsUrl: preset.docsUrl, - }; - } - return { kind: "disconnected", affordance: "oauth" }; - } - - const descriptor = CONNECTOR_REGISTRY[slug]; - if (descriptor === undefined) { - return { kind: "error", message: "This service isn't available here." }; - } - const [providers, oauthConfigured] = await Promise.all([ - listProviders(tenantId), - descriptor.oauth !== undefined - ? fetchOAuthConfigured(tenantId) - : Promise.resolve>>({}), - ]); - if (providers.some((provider) => provider.name === descriptor.id)) { - return { kind: "connected" }; - } - if (descriptor.oauth !== undefined && oauthConfigured[slug] === true) { - return { kind: "disconnected", affordance: "oauth" }; - } - if (descriptor.authKind === "api-key") { - return { - kind: "disconnected", - affordance: "api-key", - docsUrl: descriptor.docsUrl, - }; - } - // A pure-OAuth connector with no configured hosted app has no - // affordance a person can use here — say so instead of rendering a - // key field no key can satisfy. - return { - kind: "error", - message: "This connect isn't set up on this server yet.", - }; - } - - async function refreshSubscribed(): Promise { - await Promise.all( - [...listeners.keys()].map(async (connectorId) => { - const query = await readState(connectorId).catch(() => ({ - kind: "error" as const, - message: "Couldn't check this connection. Try again.", - })); - fanOut(connectorId, query); - }), - ); - } - - return { - getConnectState(connectorId) { - return readState(connectorId).catch(() => ({ - kind: "error" as const, - message: "Couldn't check this connection. Try again.", - })); - }, - subscribeConnectState(connectorId, onUpdate) { - const set = listeners.get(connectorId) ?? new Set(); - set.add(onUpdate); - listeners.set(connectorId, set); - return () => { - set.delete(onUpdate); - }; - }, - notifySettingsChanged() { - return refreshSubscribed(); - }, - async connect(connectorId) { - const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(MCP_PRESETS, slug); - if (preset !== undefined && preset.connectionMode === "keyless") { - try { - await connectMcpPreset(tenantId, preset.slug, undefined); - } catch { - return { ok: false, message: "Couldn't connect. Try again." }; - } - fanOut(connectorId, { kind: "connected" }); - return { ok: true }; - } - const startHref = - preset !== undefined - ? `/api/tenants/${tenantId}/mcp-servers/oauth/${preset.slug}/start?return=${encodeURIComponent(returnPath)}` - : oauthStartHref(tenantId, slug, returnPath); - window.location.href = startHref; - return { ok: true }; - }, - async submitKey(connectorId, key) { - const slug = bareConnectorId(connectorId); - const preset = mcpPresetBySlug(MCP_PRESETS, slug); - if (preset !== undefined && preset.connectionMode === "token") { - try { - await connectMcpPreset(tenantId, preset.slug, key); - } catch (cause) { - const message = - cause instanceof McpServersApiError - ? cause.message - : "Couldn't connect with that token. Try again."; - return { ok: false, message }; - } - fanOut(connectorId, { kind: "connected" }); - return { ok: true }; - } - try { - await completeConnectorCredential(tenantId, slug, key); - } catch (cause) { - const message = - cause instanceof ConnectionsApiError - ? cause.message - : "Couldn't connect with that key. Try again."; - return { ok: false, message }; - } - fanOut(connectorId, { kind: "connected" }); - return { ok: true }; - }, - }; -} diff --git a/apps/web/src/granola-plugin-availability.test.ts b/apps/web/src/granola-plugin-availability.test.ts deleted file mode 100644 index 656e0dfdf4..0000000000 --- a/apps/web/src/granola-plugin-availability.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { fetchGranolaPluginConnected } from "./granola-plugin-availability"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -describe("fetchGranolaPluginConnected", () => { - test("false on 404 — Granola not connected", async () => { - globalThis.fetch = (async () => new Response(null, { status: 404 })) as unknown as typeof fetch; - expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); - }); - - test("true when the resolved credential is active", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - id: "cred_1", - tenantId: "tnt_1", - name: "Granola", - status: "active", - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as unknown as typeof fetch; - expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); - }); - - test("true when the credential needs attention (expired/error)", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - id: "cred_1", - tenantId: "tnt_1", - name: "Granola", - status: "expired", - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as unknown as typeof fetch; - expect(await fetchGranolaPluginConnected("tnt_1")).toBe(true); - }); - - test("false when the credential is revoked", async () => { - globalThis.fetch = (async () => - new Response( - JSON.stringify({ - id: "cred_1", - tenantId: "tnt_1", - name: "Granola", - status: "revoked", - }), - { status: 200, headers: { "content-type": "application/json" } }, - )) as unknown as typeof fetch; - expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); - }); - - test("false on network failure — never claim connected", async () => { - globalThis.fetch = (async () => { - throw new Error("offline"); - }) as unknown as typeof fetch; - expect(await fetchGranolaPluginConnected("tnt_1")).toBe(false); - }); -}); diff --git a/apps/web/src/granola-plugin-availability.ts b/apps/web/src/granola-plugin-availability.ts deleted file mode 100644 index 04d0ec8c75..0000000000 --- a/apps/web/src/granola-plugin-availability.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Whether this tenant has Granola connected — the routine trigger popover -// (see `shell/routine-panel.tsx`) reads this so an unconnected bench never -// offers "Granola call notes" as if it were a working trigger. -// Tenant-scoped: Granola's credential resolves through the ancestor chain -// (`GET /credentials/resolve/Granola`). -import { type } from "arktype"; -import { useQuery } from "@tanstack/react-query"; - -import { tenantKeys } from "./query-client"; - -const ResolvedCredential = type({ - status: "'active' | 'expired' | 'revoked' | 'error'", -}); - -/** True when Granola is connected (or needs attention) for `tenantId`. A - * missing, revoked, or unreadable credential is not offerable. */ -export async function fetchGranolaPluginConnected(tenantId: string): Promise { - const response = await fetch( - `/api/tenants/${tenantId}/credentials/resolve/${encodeURIComponent("Granola")}`, - { headers: { accept: "application/json" } }, - ).catch(() => null); - if (response === null || response.status === 404) return false; - if (!response.ok) return false; - const body: unknown = await response.json().catch(() => undefined); - const parsed = ResolvedCredential(body); - if (parsed instanceof type.errors) return false; - // Revoked reads as not connected — same rule as - // `@corbits/connections/plugins`' resolveOne. - return parsed.status === "active" || parsed.status === "expired" || parsed.status === "error"; -} - -/** Absent (still fetching) never claims Granola is connected — the trigger - * popover's own "hide when not available" default. */ -export function useGranolaPluginConnected(tenantId: string | null): boolean { - const { data } = useQuery({ - queryKey: - tenantId === null - ? (["granola-plugin-connected", "none"] as const) - : ([...tenantKeys.all(tenantId), "granola-plugin-connected"] as const), - queryFn: () => fetchGranolaPluginConnected(tenantId as string), - enabled: tenantId !== null, - }); - return data ?? false; -} diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 9db180586f..d87b0fd016 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -12,7 +12,6 @@ import { useCallback, useEffect, useMemo } from "react"; import { fetchArtifactDetail } from "../api"; import { createChatApprovalActions } from "../approval-actions"; -import { createChatConnectServiceActions } from "../connect-service-actions"; import { useBench } from "../bench-context"; import { useSignOut, useSessionUser } from "../navigation"; @@ -32,11 +31,7 @@ import { } from "../workbench-path"; import { reportWorkbenchNotFound } from "../workbench-not-found-event"; import { recordLastWorkbenchId } from "../last-workbench"; -import { ONBOARDING_PATH, MISSION_CONTROL_PATH, NEW_WORKBENCH_PATH } from "../routes"; -import { - useProviderHealthBanner, - useRequestPluginsConnect, -} from "../shell/provider-health-context"; +import { MISSION_CONTROL_PATH, NEW_WORKBENCH_PATH } from "../routes"; import { useOpenArtifactInCanvas, useOpenProfileInCanvas } from "../shell/canvas-availability"; import { useRegisterComposerInsert } from "../shell/composer-insertion"; import { StageTopBar } from "../shell/stage-top-bar"; @@ -103,35 +98,10 @@ export function ChatPage({ () => (tenantId === null ? undefined : createChatApprovalActions(tenantId, queryClient)), [tenantId, queryClient], ); - const connectServiceActions = useMemo( - () => (tenantId === null ? undefined : createChatConnectServiceActions(tenantId, path)), - [tenantId, path], - ); - - // The in-chat "Fix this connection" affordance's deep link — - // the exact same hop the shell banner's own "Fix it" takes, reusing - // `providerHealthBanner`'s current provider (chat-ui only sees a - // classified reply's prose, never which provider it named — see - // `inference-failure.ts`'s own header) rather than inventing a second - // routing decision. Falls back to a bare Plugins visit if the banner's - // provider isn't currently known (an edge case: the health poll hasn't - // landed yet, or the incident already cleared between the reply and - // the click). - const providerHealthBanner = useProviderHealthBanner(); - const requestPluginsConnect = useRequestPluginsConnect(); - - const handleFixConnection = useCallback(() => { - if (providerHealthBanner === null) { - navigate("/plugins"); - return; - } - if (providerHealthBanner.zeroWorkingProviders) { - navigate(ONBOARDING_PATH); - return; - } - requestPluginsConnect(providerHealthBanner.provider); - navigate("/plugins"); - }, [providerHealthBanner, requestPluginsConnect, navigate]); + // The in-chat "Fix this connection" affordance's deep link — Settings + // is where a credential now lives (the stock credentials route), so + // there is no provider-specific hop left to make. + const handleFixConnection = useCallback(() => navigate("/settings"), [navigate]); // whether this tenant can actually run inference — never // whether a `model_provider` row merely exists, since seeding mints @@ -246,7 +216,6 @@ export function ChatPage({ {...(hasUsableModel !== undefined ? { hasUsableModel } : {})} onConnectModel={handleConnectModel} {...(approvalActions !== undefined ? { approvalActions } : {})} - {...(connectServiceActions !== undefined ? { connectServiceActions } : {})} onWorkbenchNotFound={reportWorkbenchNotFound} onGoToMissionControl={() => navigate(MISSION_CONTROL_PATH)} onNewWorkbench={() => navigate(NEW_WORKBENCH_PATH)} diff --git a/apps/web/src/pages/plugins-page.tsx b/apps/web/src/pages/plugins-page.tsx deleted file mode 100644 index 08dc376319..0000000000 --- a/apps/web/src/pages/plugins-page.tsx +++ /dev/null @@ -1,365 +0,0 @@ -// The Plugins gallery route — a thin composition, same shape as -// `library-page.tsx`: page-level data fetching and wiring live here, -// presentation lives in `@/tools`. This page reads the -// currently selected bench tenant the same way every other Settings -// surface does (`useBench().selectedTenantId`) — the owner's "PARENT -// (account/root) tenant" framing maps onto whichever tenant that is: the -// app has no separate "account" tenant type above a bench today, and this -// page's own data is always that tenant's OWN plugin connections plus -// whatever it inherits from its ancestors, exactly like every other -// Connections surface. See this ticket's report for the full grounding. -// -// Skills gets the same gallery treatment as plugins (owner ruling): cards, -// not the Skills roster's list rows. Opening a card (or creating a skill) -// navigates to `/skills/` — the same path `SkillsPage.open` already -// uses. Mutations live there; this gallery never mounts a twin editor. - -import { Button, PageShell, RichEmptyState } from "@corbits/react-ui"; -import { WorkbenchLoadingState } from "@/chat"; -import { - PluginsGallery, - PluginConnectPanel, - type PluginsGalleryTab, - type PluginPanelSubject, -} from "@/tools"; -import type { ResolvedPlugin } from "@corbits/connections/plugins"; -import { listPluginsForTenant } from "@corbits/connections/plugins"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { MCP_PRESETS } from "@corbits/connections/mcp-presets"; -import { Plus, SquaresFour, Warning } from "@/lib/icons"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { useBench } from "../bench-context"; -import { SKILLS_PATH_PREFIX } from "../path-ids"; -import { - useClearPendingConnectProvider, - usePendingConnectProvider, - useRequestPluginsConnect, -} from "../shell/provider-health-context"; -import { StageTopBar } from "../shell/stage-top-bar"; -import { createSkill, listSkills, type SkillSummary } from "../skills-api"; -import { CreateSkillDialog, type SkillCreateInput } from "./create-skill-dialog"; - -type PluginsState = - | { readonly status: "loading" } - | { readonly status: "ready"; readonly plugins: readonly ResolvedPlugin[] } - | { readonly status: "error"; readonly message: string }; - -type SkillsState = - | { readonly status: "loading" } - | { readonly status: "ready"; readonly skills: readonly SkillSummary[] } - | { readonly status: "error"; readonly message: string }; - -function messageOf(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - -function canOpenPluginPanel(plugin: ResolvedPlugin): boolean { - return plugin.status !== "not_connected"; -} - -export function PluginsRoute({ - path: _path, - navigate, -}: { - readonly path: string; - readonly navigate: (to: string) => void; -}) { - const { selectedTenantId } = useBench(); - const [pluginsState, setPluginsState] = useState({ - status: "loading", - }); - const [skillsState, setSkillsState] = useState({ - status: "loading", - }); - const [openPlugin, setOpenPlugin] = useState(null); - const [createSkillOpen, setCreateSkillOpen] = useState(false); - const [activeTab, setActiveTab] = useState("plugins"); - const [galleryQuery, setGalleryQuery] = useState(""); - // Set when the shell banner's "Fix it" deep link named a provider this - // gallery has no matching card for — rather than the deep - // link silently no-oping, this renders a notice pointing back at the - // gallery itself. - const [connectDeepLinkNotFound, setConnectDeepLinkNotFound] = useState(false); - // A preset deep link's slug, passed to the MCP presets - // section so it can focus that preset's own card once its catalog - // has loaded — cleared as soon as the section has acted on it. - const [autoConnectPresetSlug, setAutoConnectPresetSlug] = useState(null); - const pendingConnectProvider = usePendingConnectProvider(); - const clearPendingConnectProvider = useClearPendingConnectProvider(); - const requestPluginsConnect = useRequestPluginsConnect(); - const openPluginPanel = useCallback((plugin: ResolvedPlugin) => { - setOpenPlugin({ kind: "connector", plugin }); - setConnectDeepLinkNotFound(false); - }, []); - - const [pluginsReloadKey, setPluginsReloadKey] = useState(0); - const [skillsReloadKey, setSkillsReloadKey] = useState(0); - - const reloadPlugins = useCallback(() => { - setPluginsReloadKey((key) => key + 1); - }, []); - - const reloadSkills = useCallback(() => { - setSkillsReloadKey((key) => key + 1); - }, []); - - // Guarded by `cancelled` (same pattern as settings-ui's `people-section`) - // so a tenant switch mid-flight can't have the previous tenant's late - // response overwrite the newly selected tenant's state. The loading - // skeleton only shows for a tenant this page hasn't fetched yet — an - // imperative reload (a connect/disconnect's `onChanged`, the error - // screen's Retry) keeps whatever is already on screen and swaps in the - // fresh data once it lands, the same way it worked before cancellation - // was added. - const pluginsLoadedTenantRef = useRef(null); - const skillsLoadedTenantRef = useRef(null); - - useEffect(() => { - if (selectedTenantId === null) return; - let cancelled = false; - const isTenantChange = pluginsLoadedTenantRef.current !== selectedTenantId; - pluginsLoadedTenantRef.current = selectedTenantId; - if (isTenantChange) setPluginsState({ status: "loading" }); - listPluginsForTenant(selectedTenantId, CONNECTOR_REGISTRY) - .then((plugins) => { - if (!cancelled) setPluginsState({ status: "ready", plugins }); - }) - .catch((cause: unknown) => { - if (!cancelled) setPluginsState({ status: "error", message: messageOf(cause) }); - }); - return () => { - cancelled = true; - }; - }, [selectedTenantId, pluginsReloadKey]); - - // Keep plugin connection status live while the gallery sits open — - // a credential expiring or a disconnect in another tab/window would - // otherwise leave "Connected" stale indefinitely. Re-read on - // visibility/focus and poll every 30s while visible, mirroring the - // pattern `ConnectionsSection` and the `subscribeConnectState` containers - // use for in-room connect cards. - useEffect(() => { - if (selectedTenantId === null) return; - // `visibilitychange` and `focus` both fire in the same tick when a tab - // regains focus; the microtask guard collapses that pair into one - // scheduled bump instead of two back-to-back reloads. - let bumpScheduled = false; - const refreshWhenVisible = () => { - if (document.visibilityState !== "visible") return; - if (bumpScheduled) return; - bumpScheduled = true; - queueMicrotask(() => { - bumpScheduled = false; - setPluginsReloadKey((key) => key + 1); - }); - }; - document.addEventListener("visibilitychange", refreshWhenVisible); - window.addEventListener("focus", refreshWhenVisible); - const interval = setInterval(refreshWhenVisible, 30_000); - return () => { - document.removeEventListener("visibilitychange", refreshWhenVisible); - window.removeEventListener("focus", refreshWhenVisible); - clearInterval(interval); - }; - }, [selectedTenantId]); - - useEffect(() => { - if (selectedTenantId === null) return; - let cancelled = false; - const isTenantChange = skillsLoadedTenantRef.current !== selectedTenantId; - skillsLoadedTenantRef.current = selectedTenantId; - if (isTenantChange) setSkillsState({ status: "loading" }); - listSkills(selectedTenantId) - .then((skills) => { - if (!cancelled) setSkillsState({ status: "ready", skills }); - }) - .catch((cause: unknown) => { - if (!cancelled) setSkillsState({ status: "error", message: messageOf(cause) }); - }); - return () => { - cancelled = true; - }; - }, [selectedTenantId, skillsReloadKey]); - - // The shell banner's "Fix it" deep link: once the gallery has - // loaded, pick up any pending provider id and open its connect panel — - // the same panel a gallery card click opens, so a person lands exactly - // where they would have clicked themselves. - useEffect(() => { - if (pluginsState.status !== "ready") return; - if (pendingConnectProvider === null) return; - const match = pluginsState.plugins.find( - (plugin) => plugin.descriptor.id === pendingConnectProvider, - ); - if (match !== undefined && canOpenPluginPanel(match)) { - openPluginPanel(match); - } else { - setConnectDeepLinkNotFound(true); - } - clearPendingConnectProvider(); - }, [pluginsState, pendingConnectProvider, clearPendingConnectProvider, openPluginPanel]); - - // `request_connection`'s fallback link: `/plugins?connect=` - // hands the connector id off through the same `requestPluginsConnect` - // path the shell banner's "Fix it" click uses, then strips only the - // `connect` param — any other query param this route is ever opened - // with (e.g. an in-flight `mcpOauth` return) must survive the rewrite. - // A curated MCP preset (Exa, Granola, Linear, ...) has no fixed - // `CONNECTOR_REGISTRY` id, so its own deep link is `mcp:` - // (`presetDeepLink` in `tools/connections/src/tool.ts`) and - // is matched against the preset catalog instead. An id neither side - // recognizes (typo, stale link) is ignored rather than surfaced as a - // notice. - useEffect(() => { - const params = new URLSearchParams(window.location.search); - const connectId = params.get("connect"); - if (connectId === null) return; - params.delete("connect"); - const rest = params.toString(); - window.history.replaceState( - null, - "", - rest === "" ? window.location.pathname : `${window.location.pathname}?${rest}`, - ); - if (connectId.startsWith("mcp:")) { - const slug = connectId.slice("mcp:".length); - if (MCP_PRESETS.some((preset) => preset.slug === slug)) { - setAutoConnectPresetSlug(slug); - } - return; - } - if (CONNECTOR_REGISTRY[connectId] !== undefined) { - requestPluginsConnect(connectId); - } - }, [requestPluginsConnect]); - - function openSkill(name: string) { - navigate(`${SKILLS_PATH_PREFIX}/${encodeURIComponent(name)}`); - } - - async function handleCreateSkill(input: SkillCreateInput) { - if (selectedTenantId === null) return; - const skill = await createSkill(selectedTenantId, { - name: input.name, - ...(input.displayName !== "" ? { displayName: input.displayName } : {}), - }); - setCreateSkillOpen(false); - reloadSkills(); - openSkill(skill.name); - } - - if (selectedTenantId === null) { - return ( -
- - - } - title="Select a workbench" - description="Open a workbench to see its plugins." - /> - -
- ); - } - - const tenantId = selectedTenantId; - - if (pluginsState.status === "error" || skillsState.status === "error") { - return ( -
- - - } - title="Couldn't load your plugins" - description="Something went wrong on our side. Try again in a moment." - actions={[ - { - label: "Retry", - onClick: () => { - reloadPlugins(); - reloadSkills(); - }, - }, - ]} - /> - -
- ); - } - - if (pluginsState.status === "loading" || skillsState.status === "loading") { - return ( -
- - - - -
- ); - } - - const skillCards = skillsState.skills.map((skill) => ({ - assetId: skill.assetId, - name: skill.name, - description: skill.description, - })); - - return ( -
- setCreateSkillOpen(true)}> - New skill - - ) : null - } - /> - - {connectDeepLinkNotFound ? ( -
- -

- Couldn't find that connection — pick it below. -

-
- ) : null} - openSkill(skill.name)} - activeTab={activeTab} - onTabChange={setActiveTab} - query={galleryQuery} - autoConnectPresetSlug={autoConnectPresetSlug} - onAutoConnectPresetHandled={() => setAutoConnectPresetSlug(null)} - /> -
- setOpenPlugin(null)} - onChanged={() => { - reloadPlugins(); - setOpenPlugin(null); - }} - /> - -
- ); -} diff --git a/apps/web/src/pages/routines-available-strings.ts b/apps/web/src/pages/routines-available-strings.ts deleted file mode 100644 index 656882e68a..0000000000 --- a/apps/web/src/pages/routines-available-strings.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Copy for the Routines page's "Available" section — kept apart -// from JSX so a wording change never touches component logic. Every -// string here reads as consumer language: no "deploy", "asset", or other -// platform-side noun (see `scripts/checks/ui-vocabulary.ts`). - -export const AVAILABLE_SECTION_TITLE = "Available"; -export const AVAILABLE_SECTION_SUBTITLE = "Workflows this workbench hasn't added yet."; - -/** - * Every connector id `WORKFLOW_CATALOG` currently names as a required - * connection (`packages/workflows/src/catalog.ts`), mapped to the - * consumer-facing name the Plugins page shows for it. A connector id - * with no entry here falls back to itself capitalized — never a raw - * lowercase id in copy, but also never a silent crash for a future - * catalog entry naming a connector this list hasn't caught up with yet. - */ -const CONNECTOR_DISPLAY_NAMES: Readonly> = { - github: "GitHub", - granola: "Granola", - linear: "Linear", - exa: "Exa", - scrapecreators: "ScrapeCreators", - attio: "Attio", -}; - -function connectorDisplayName(connectorId: string): string { - return ( - CONNECTOR_DISPLAY_NAMES[connectorId] ?? - connectorId.charAt(0).toUpperCase() + connectorId.slice(1) - ); -} - -export function missingConnectionsReason(missingConnections: readonly string[]): string { - const names = missingConnections.map(connectorDisplayName); - if (names.length === 1) { - return `Connect ${names[0]} first.`; - } - return `Connect ${names.join(" and ")} first.`; -} - -export const CONNECT_LINK_LABEL = "Go to Plugins"; diff --git a/apps/web/src/pages/routines-page.tsx b/apps/web/src/pages/routines-page.tsx index dbb2ab11d5..89537331d2 100644 --- a/apps/web/src/pages/routines-page.tsx +++ b/apps/web/src/pages/routines-page.tsx @@ -1,11 +1,6 @@ // Routines: an ops table of authored workflow definitions that carry a // ScheduleTrigger, including paused (`stopped`) ones. Pause/resume and // run-now are the only writes; schedules are authored on the definition. -// The "Available" section is scoped to the current bench only -// — adding a catalog workflow is a single-tenant write, unlike the -// scheduled roster above, which aggregates every bench the account -// belongs to. -import { useQuery } from "@tanstack/react-query"; import { EmptyState, RichEmptyState, @@ -24,65 +19,11 @@ import { cronSentence } from "@corbits/workflows/client"; import { useGlobalRoutines, useRoutineActions } from "../global-routines"; import type { GlobalRoutineRow } from "../global-routines"; import { routineDetailPath } from "../global-routines"; -import { useBench } from "../bench-context"; -import { tenantKeys } from "../query-client"; -import { listAvailableCatalogWorkflows, type AvailableCatalogWorkflow } from "../routines-api"; import { Link } from "../navigation"; -import { PLUGINS_PATH_PREFIX } from "../path-ids"; import { StageTopBar } from "../shell/stage-top-bar"; -import { - AVAILABLE_SECTION_SUBTITLE, - AVAILABLE_SECTION_TITLE, - CONNECT_LINK_LABEL, - missingConnectionsReason, -} from "./routines-available-strings"; export type { GlobalRoutineRow } from "../global-routines"; -export function AvailableCatalogWorkflowsSection({ tenantId }: { readonly tenantId: string }) { - const query = useQuery({ - queryKey: tenantKeys.availableCatalogWorkflows(tenantId), - queryFn: () => listAvailableCatalogWorkflows(tenantId), - }); - - if (query.isLoading) return null; - const items: readonly AvailableCatalogWorkflow[] = query.data ?? []; - if (items.length === 0) return null; - - return ( -
-
-

{AVAILABLE_SECTION_TITLE}

-

{AVAILABLE_SECTION_SUBTITLE}

-
-
    - {items.map((entry) => { - return ( -
  • -
    - {entry.displayName} - {entry.description} - {!entry.connectionsSatisfied ? ( - - {missingConnectionsReason(entry.missingConnections)} - - {CONNECT_LINK_LABEL} - - - ) : null} -
    -
  • - ); - })} -
-
- ); -} - export function scheduleSentence(cron: string): string { return cronSentence(cron) ?? cron; } @@ -160,7 +101,6 @@ export function RoutinesRoute() { const routinesQuery = useGlobalRoutines(); const actions = useRoutineActions(); const rows = routinesQuery.kind === "ready" ? routinesQuery.data : []; - const { selectedTenantId } = useBench(); return (
@@ -169,9 +109,6 @@ export function RoutinesRoute() { subtitle="Scheduled workflows. Pause, resume, or run now." />
- {selectedTenantId !== null ? ( - - ) : null} {routinesQuery.kind === "loading" ? (
} title="Loading routines…" /> diff --git a/apps/web/src/query-client.ts b/apps/web/src/query-client.ts index e41c61ad35..3775e77361 100644 --- a/apps/web/src/query-client.ts +++ b/apps/web/src/query-client.ts @@ -82,10 +82,7 @@ export const tenantKeys = { * (see `pending-approvals.ts`). */ runView: (tenantId: string, runId: string) => ["tenant", tenantId, "runs", runId] as const, routines: (tenantId: string) => ["tenant", tenantId, "routines"] as const, - availableCatalogWorkflows: (tenantId: string) => - ["tenant", tenantId, "routines", "available"] as const, skills: (tenantId: string) => ["tenant", tenantId, "skills"] as const, - mcpServers: (tenantId: string) => ["tenant", tenantId, "mcp-servers"] as const, routineRuns: (tenantId: string, routineId: string) => ["tenant", tenantId, "routines", routineId, "runs"] as const, routineRunHistories: (tenantId: string) => ["tenant", tenantId, "routine-run-histories"] as const, diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 418a94b140..53fa3b3550 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -1,8 +1,8 @@ // The route table: one entry per screen, consumed by the command palette // (label) and the route switch (render), so navigation and pages cannot // drift apart. The sidebar itself lists workbenches (conversations), not -// routes — the first-run footer reaches Routines, Files, Skills, Agents, -// and Plugins; Insights joins that rail only given honest usage. +// routes — the first-run footer reaches Routines, Files, Skills, and +// Agents; Insights joins that rail only given honest usage. // Insights and Settings stay reachable by deep link and the // palette even when they are off the rail. Conversation deep links // (`/w/:workbenchId`) stay routable; `/` is the Myra land hop (ensure + @@ -23,7 +23,6 @@ import { FlowArrow, FolderOpen, Lightning, - PuzzlePiece, Robot, SlidersHorizontal, SquaresFour, @@ -50,7 +49,7 @@ import { // Each signed-in page is a dynamic import so Vite emits one chunk per // screen. Static imports here pulled chat-ui, artifact-ui, settings-ui, -// plugins-ui, and insights into a single 1.2 MB SPA. +// and insights into a single 1.2 MB SPA. const HomeRoute = lazy(async () => ({ default: (await import("./pages/home-page")).HomeRoute, @@ -79,9 +78,6 @@ const SkillsRoute = lazy(async () => ({ const InsightsRoute = lazy(async () => ({ default: (await import("./pages/insights-page")).InsightsRoute, })); -const PluginsRoute = lazy(async () => ({ - default: (await import("./pages/plugins-page")).PluginsRoute, -})); const SettingsRoute = lazy(async () => ({ default: (await import("./pages/settings-page")).SettingsRoute, })); @@ -137,9 +133,6 @@ const SLUG_SEGMENT = "/:slug"; export const AGENT_DETAIL_PATH = `${AGENTS_PATH_PREFIX}${SLUG_SEGMENT}`; export const SKILL_DETAIL_PATH = `${SKILLS_PATH_PREFIX}${SLUG_SEGMENT}`; -// Plugin detail (`/plugins/:slug`) is parked with. removed -// the "still being built" stub so gallery/palette click-throughs do not -// promise a page that is only a placeholder. /** * Routines are addressed by id, not by slug. DESIGN.md allows a slug in a @@ -207,9 +200,7 @@ export type AppRoute = { * conversation deep links (which also match when Myra land `/` is active) * and the slug-addressed detail routes (`/agents/:slug`). Other routes are * exact path matches. A roster prefix still matches its own nested paths, - * so the sidebar footer row stays lit on a detail screen. Plugins is exact - * only: until lands a real detail page, a slug under `/plugins` is - * unroutable rather than a stub. + * so the sidebar footer row stays lit on a detail screen. */ export function matchesRoute(routePath: string, path: string): boolean { if (routePath === WORKBENCH_PATH_PREFIX) { @@ -384,16 +375,6 @@ export const APP_ROUTES: readonly AppRoute[] = [ icon: , render: (path: string) => , }, - { - // First-run footer rail destination. No `/plugins/:slug` until - // (unlinked the stub). - path: "/plugins", - label: "Plugins", - icon: , - render: (path: string, navigate: (to: string) => void) => ( - - ), - }, { path: SETTINGS_PATH, label: "Settings", @@ -415,7 +396,7 @@ function routesInOrder(paths: readonly string[]): readonly AppRoute[] { /** * Everything the command palette treats as a product destination (its * "Pages" group). The first-run sidebar footer reaches Routines / Files / - * Skills / Agents / Plugins (and Insights only given honest + * Skills / Agents (and Insights only given honest * usage); Insights and Settings stay palette- and * deep-link-reachable even when they are off the rail. */ @@ -424,7 +405,6 @@ export const NAV_ROUTES: readonly AppRoute[] = routesInOrder([ "/files", "/skills", "/agents", - "/plugins", "/insights", SETTINGS_PATH, ]); diff --git a/apps/web/src/routines-api.ts b/apps/web/src/routines-api.ts index 45d23cd3db..9ec338be15 100644 --- a/apps/web/src/routines-api.ts +++ b/apps/web/src/routines-api.ts @@ -2,21 +2,17 @@ // ScheduleTrigger: list, run-now, and pause/resume. Pause/resume is the // same agent-directory status PUT seed uses (`stopped` / `deployed`). // -// the hub routes `listScheduledWorkflows` and -// `runScheduledWorkflowNow`/`listAvailableCatalogWorkflows` used to call -// (`@corbits/workflows`'s deleted `./schedule/scheduled-route.ts`) are -// gone. Owner ruling: schedule state derives from stock reads client-side -// or stays a package-internal pure function — no hub route. Stock's -// `GET /workflows/definitions` (`vendor/intx/hub-api`) exposes no wire -// projection, so there is no stock-derivable way to know which -// definitions carry a `ScheduleTrigger` or what its cron is, and -// `listAvailableCatalogWorkflows`'s connection-satisfaction read has no -// stock equivalent either. Both resolve to an empty list rather than -// fetch a route that no longer exists (same pattern as `tenantKeys`' -// `routineActivity` comment in `query-client.ts` for its deleted -// `feed=fires` route): the Routines roster and Available section render -// their existing empty states until an upstream ask lands. See the -// PR for exactly what is missing. +// the hub route `listScheduledWorkflows`/`runScheduledWorkflowNow` used +// to call (`@corbits/workflows`'s deleted `./schedule/scheduled-route.ts`) +// is gone. Owner ruling: schedule state derives from stock reads +// client-side or stays a package-internal pure function — no hub route. +// Stock's `GET /workflows/definitions` (`vendor/intx/hub-api`) exposes no +// wire projection, so there is no stock-derivable way to know which +// definitions carry a `ScheduleTrigger` or what its cron is. It resolves +// to an empty list rather than fetch a route that no longer exists (same +// pattern as `tenantKeys`' `routineActivity` comment in `query-client.ts` +// for its deleted `feed=fires` route): the Routines roster renders its +// existing empty state until an upstream ask lands. import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; @@ -38,17 +34,6 @@ export const ScheduledWorkflowDefinition = type({ export type ScheduledWorkflowDefinition = typeof ScheduledWorkflowDefinition.infer; -export const AvailableCatalogWorkflow = type({ - assetName: "string", - displayName: "string", - description: "string", - requiredConnections: "string[]", - missingConnections: "string[]", - connectionsSatisfied: "boolean", -}); - -export type AvailableCatalogWorkflow = typeof AvailableCatalogWorkflow.infer; - /** no route exists at this path any more — kept as a documented * dead address, not a live fetch target, for any caller that still reads * it for logging/keys. */ @@ -56,19 +41,6 @@ export function scheduledWorkflowsPath(tenantId: string): string { return `/api/tenants/${tenantId}/workflows/scheduled`; } -/** no route exists at this path any more — see the file header. */ -export function availableCatalogWorkflowsPath(tenantId: string): string { - return `/api/tenants/${tenantId}/workflows/available`; -} - -/** always empty — see the file header for why there is no - * stock-derivable replacement yet. */ -export function listAvailableCatalogWorkflows( - _tenantId: string, -): Promise { - return Promise.resolve([]); -} - export function scheduledWorkflowRunPath(tenantId: string, definitionId: string): string { return `/api/tenants/${tenantId}/workflows/scheduled/${encodeURIComponent(definitionId)}/run`; } diff --git a/apps/web/src/settings/api-request.ts b/apps/web/src/settings/api-request.ts index 7a42a6b8ac..9edf4d2320 100644 --- a/apps/web/src/settings/api-request.ts +++ b/apps/web/src/settings/api-request.ts @@ -1,6 +1,6 @@ // Shared fetch+parse wrapper for every settings-ui API seam -// (credentials-api.ts, connections-api.ts, access-policy-api.ts, -// granola-webhook-api.ts): the same envelope-first error message on every +// (credentials-api.ts, access.ts, tenancy-api.ts): the same +// envelope-first error message on every // non-2xx response, matching `apps/web/src/onboarding.ts`'s // `readErrorEnvelope` — the hub's own `{error:{userMessage, refId}}` body // wins when present, and the fallback names what was happening ("while diff --git a/apps/web/src/settings/connections-api.ts b/apps/web/src/settings/connections-api.ts deleted file mode 100644 index 9a53b2538e..0000000000 --- a/apps/web/src/settings/connections-api.ts +++ /dev/null @@ -1,96 +0,0 @@ -// Connections section seam to the connector-registry routes -// (`/api/tenants/:tenantId/connections/:connectorId/*`, `@corbits/connections`'s -// route factory). `credentials-api.ts`-shaped: same fetch wrapper, same -// error class convention, arktype at the trust boundary. - -import { type } from "arktype"; - -import { apiRequest, type Validator } from "./api-request"; - -export class ConnectionsApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -const CompleteResult = type({ - credentialId: "string", - status: "'active'", - "modelGuidance?": "string", -}); - -const OAuthConfiguredResult = type("Record"); - -function request( - path: string, - schema: Validator, - verb: string, - init?: RequestInit, -): Promise { - return apiRequest(path, schema, verb, ConnectionsApiError, init); -} - -/** - * Which oauth-pkce/oauth-code connectors have a registered OAuth app - * (a client id) configured, keyed by connector id — read ahead of - * rendering a card's Connect action so an unregistered connector shows - * the muted "Not configured" state instead of a button that would - * round-trip into the provider's own consent screen and dead-end. - */ -export function fetchOAuthConfigured(tenantId: string): Promise> { - return request( - `/api/tenants/${tenantId}/connections/oauth-configured`, - OAuthConfiguredResult, - "loading connection status", - ); -} - -/** - * The one connect action: the server proves the pasted key - * against the connector's own probe and only stores it once that probe - * accepts — there is no separate client-driven "test" round-trip before - * this call. A rejected probe 422s with the probe's own message, which - * throws `ConnectionsApiError` (status 422); the caller renders that - * inline as the normal connect-failed state. - */ -export function completeConnectorCredential( - tenantId: string, - connectorId: string, - apiKey: string, -): Promise<{ - credentialId: string; - status: "active"; - modelGuidance?: string; -}> { - // A key copied from a provider console often carries a trailing - // newline; sent verbatim the provider 401s a valid key. - return request( - `/api/tenants/${tenantId}/connections/${connectorId}/complete`, - CompleteResult, - "saving that connection", - { method: "POST", body: JSON.stringify({ apiKey: apiKey.trim() }) }, - ); -} - -/** - * Disconnects a connector through `packages/connections`' own - * orchestrated route — never `DELETE /credentials/:id` directly. That - * native route alone 500s for any inference-provider connector once - * `seedCatalog` has planted a catalog provider row against the - * credential (`model_provider.credential_id` is `ON DELETE RESTRICT`); - * this route deletes the catalog provider first (cascading its - * offerings), then the credential provider row (cascading its - * credentials) — see `@corbits/connections`' `disconnectConnector` for - * the full ordering and why. - */ -export function disconnectConnector(tenantId: string, connectorId: string): Promise { - return request( - `/api/tenants/${tenantId}/connections/${connectorId}/disconnect`, - (data) => data as void, - "disconnecting that connection", - { method: "DELETE" }, - ); -} diff --git a/apps/web/src/settings/connections-pinned-by.test.ts b/apps/web/src/settings/connections-pinned-by.test.ts deleted file mode 100644 index 203b4541ac..0000000000 --- a/apps/web/src/settings/connections-pinned-by.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; - -import { CONNECTOR_PINNED_WORKFLOWS } from "./connections-pinned-by"; - -describe("connections pinned-by", () => { - test("pins every workflow-catalog entry to the same connectors its own requiredConnections list names", () => { - const pinnedAssetNamesByConnector = new Map( - Object.entries(CONNECTOR_PINNED_WORKFLOWS).map(([connectorId, assetNames]) => [ - connectorId, - new Set(assetNames), - ]), - ); - for (const entry of WORKFLOW_CATALOG) { - for (const connectorId of entry.requiredConnections) { - const pinned = pinnedAssetNamesByConnector.get(connectorId); - expect(pinned?.has(entry.assetName)).toBe(true); - } - } - }); - - test("manus pins the assistant", () => { - expect(CONNECTOR_PINNED_WORKFLOWS.manus).toEqual(["assistant"]); - }); -}); diff --git a/apps/web/src/settings/connections-pinned-by.ts b/apps/web/src/settings/connections-pinned-by.ts deleted file mode 100644 index d95b338469..0000000000 --- a/apps/web/src/settings/connections-pinned-by.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Which deployed workflow asset names pin a given connector's tool package, -// per toolPackagePins mechanism. This is a hand-maintained -// approximation, not a live query against deployed definitions — the -// settings-ui card labels it "Used by workflows:" for readability, but -// carries the approximation caveat on a title/tooltip -// (connectionsPinnedByApproximationNote) rather than baking it into the -// label itself, until item 9 (tool-package credential-binding -// adoption) makes it exact. -// -// The shipped workflow catalog was deleted (owner ruling 2026-09-17): -// Myra creates workflows dynamically, so no connector is pinned by a -// seeded workflow asset name any more. -export const CONNECTOR_PINNED_WORKFLOWS: Readonly> = { - manus: ["assistant"], -}; diff --git a/apps/web/src/settings/connections-section.tsx b/apps/web/src/settings/connections-section.tsx deleted file mode 100644 index fe1631a3b0..0000000000 --- a/apps/web/src/settings/connections-section.tsx +++ /dev/null @@ -1,892 +0,0 @@ -// The "Connections" settings section: a status grid over every connector -// this bench can talk to (inference providers, tool-package api keys, and -// the two existing OAuth connectors), plus the raw credentials table as an -// "Advanced" escape hatch for credential types no connector card covers -// (a certificate, an `other`-typed row). - -import { - Button, - ConfirmButton, - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - EmptyState, - InfoTooltip, - Input, - SettingsPanel, - toast, -} from "@corbits/react-ui"; -import { - connectorDescriptors, - CONNECTOR_REGISTRY, - type ConnectorDescriptor, -} from "@corbits/connections/registry"; -import { MCP_PRESET_CONNECTOR_IDS } from "@corbits/connections/mcp-presets"; -import { workflowDisplayName } from "@corbits/workflows/catalog"; -import { - buildEffectiveInferenceRows, - computeGlobalRoutePatches, - computeMakeDefaultPatches, - getResolvedCatalog, - listOwnOfferings, - orderedGlobalInferenceRows, - providerDisplayName, - updateOwnOffering, - type EffectiveInferenceRow, -} from "@/settings/inference"; -import type { ModelInfo } from "@intx/types"; -import { ArrowDown, ArrowUp, Cpu, Plugs, Robot } from "@/lib/icons"; -import { useEffect, useState } from "react"; - -import type { APIQuery } from "@/lib/api-query"; -import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query"; -import { - ConnectionsApiError, - completeConnectorCredential, - disconnectConnector, - fetchOAuthConfigured, -} from "./connections-api"; -import { CONNECTOR_PINNED_WORKFLOWS } from "./connections-pinned-by"; -import { connectorStatus, type ConnectorStatusResult } from "./connections-status"; -import { listCredentials, listProviders, type Credential, type Provider } from "./credentials-api"; -import { SETTINGS_STRINGS } from "./strings"; - -// `@corbits/connections/registry` is the only subpath this browser -// bundle may import — its main export pulls in server-only hono routing. - -type OAuthConnectorCard = { - readonly id: "openrouter" | "huggingface"; - readonly displayName: string; -}; - -const OAUTH_CARDS: readonly OAuthConnectorCard[] = [ - { id: "openrouter", displayName: "OpenRouter" }, - { id: "huggingface", displayName: "Hugging Face" }, -]; - -/** - * Exported so other surfaces (the plugins gallery's connect panel) can - * send every OAuth-capable connector through the same tenant-scoped - * `connections/oauth` mount instead of re-deriving this URL. Onboarding's - * own `/api/onboarding/oauth/...` mount serves only its first-login - * OpenRouter/Hugging Face flow and is never a connect surface's target - *. - */ -export function oauthStartHref( - tenantId: string, - connectorId: string, - returnPath = "/settings/connections", -): string { - return `/api/tenants/${tenantId}/connections/oauth/${connectorId}/start?return=${encodeURIComponent(returnPath)}`; -} - -type ConnectionsData = { - readonly credentials: readonly Credential[]; - readonly providers: readonly Provider[]; - readonly oauthConfigured: Readonly>; - /** The resolved model catalog — read only to derive each connected - * inference provider's one default model (replacement for - * the removed Models settings page; see `defaultModelForProvider`'s - * own header for why this is never a second, hand-maintained notion of - * "the" model). */ - readonly models: readonly ModelInfo[]; - /** This tenant's own offering ids — the provenance source for which of - * `models`' offerings a default-model pick can actually PATCH (only a - * "set-here" offering; see `computeMakeDefaultPatches`). */ - readonly ownOfferingIds: ReadonlySet; -}; - -/** - * The api-key connector row list, on its own: every credentials/providers - * fetch, the connect/reconnect dialog, and disconnect all owned here so - * a caller only supplies the data it already has and a place to send a - * reload/error signal. `ConnectionsSection` composes this with the OAuth - * row pair and the advanced credentials table for the full Settings > - * Connections page — its one consumer today. Connecting tools lives only - * in Settings and the Plugins gallery, never in onboarding. Renders bare - * `ConnectorRow`s — not wrapped in - * `.settings-connections-list` itself — so a caller controls the list - * container (and can put other rows, like the OAuth pair, in the same - * list alongside these). - */ -export function ConnectorRowList({ - tenantId, - credentials, - providers, - models, - ownOfferingIds, - filter, - onReload, - onError, - onConnected, -}: { - readonly tenantId: string; - readonly credentials: readonly Credential[]; - readonly providers: readonly Provider[]; - readonly models: readonly ModelInfo[]; - readonly ownOfferingIds: ReadonlySet; - /** Narrows which registry entries render a row. Defaults to every - * api-key connector (every entry with a `probe`) — OAuth connectors - * are never included here regardless of filter, since this list has - * no OAuth flow of its own. */ - readonly filter?: (descriptor: ConnectorDescriptor) => boolean; - readonly onReload: () => void; - readonly onError?: (message: string | null) => void; - /** Fires only on a successful connect — never on disconnect/revoke — - * for callers that need to distinguish "something got connected this - * session" from "the list changed." */ - readonly onConnected?: () => void; -}) { - const [dialogDescriptor, setDialogDescriptor] = useState(null); - const [dialogMode, setDialogMode] = useState<"connect" | "reconnect">("connect"); - - function handleDisconnect(connectorId: string) { - onError?.(null); - disconnectConnector(tenantId, connectorId) - .then(() => { - onReload(); - toast(SETTINGS_STRINGS.credentialRevokedToast); - }) - .catch(() => onError?.(SETTINGS_STRINGS.connectionsDisconnectError)); - } - - const descriptors = connectorDescriptors(CONNECTOR_REGISTRY) - .filter((descriptor) => descriptor.probe !== undefined) - .filter((descriptor) => !MCP_PRESET_CONNECTOR_IDS.includes(descriptor.id)) - .filter(filter ?? (() => true)); - - const effectiveRows = buildEffectiveInferenceRows(models, ownOfferingIds); - - return ( - <> - {descriptors.map((descriptor) => ( - row.providerName === descriptor.id) - .map((row) => row.canonicalName), - ).size - } - onConnect={() => { - setDialogMode("connect"); - setDialogDescriptor(descriptor); - }} - onReconnect={() => { - setDialogMode("reconnect"); - setDialogDescriptor(descriptor); - }} - onDisconnect={() => handleDisconnect(descriptor.id)} - /> - ))} - setDialogDescriptor(null)} - onConnected={() => { - setDialogDescriptor(null); - onReload(); - onConnected?.(); - }} - /> - - ); -} - -export function ConnectionsSection({ tenantId }: { readonly tenantId: string | null }) { - const [query, setQuery] = useState>({ - kind: "loading", - }); - const [reloadKey, setReloadKey] = useState(0); - const [rowError, setRowError] = useState(null); - - function reload() { - setReloadKey((value) => value + 1); - } - - useEffect(() => { - if (tenantId === null) return; - let cancelled = false; - setQuery({ kind: "loading" }); - Promise.all([ - listCredentials(tenantId), - listProviders(tenantId), - fetchOAuthConfigured(tenantId), - getResolvedCatalog(tenantId), - listOwnOfferings(tenantId), - ]) - .then(([credentials, providers, oauthConfigured, models, ownOfferings]) => { - if (!cancelled) - setQuery({ - kind: "ready", - data: { - credentials, - providers, - oauthConfigured, - models, - ownOfferingIds: new Set(ownOfferings.map((offering) => offering.id)), - }, - }); - }) - .catch((cause: unknown) => { - if (cancelled) return; - if (cause instanceof UnauthenticatedError) { - setQuery({ kind: "unauthenticated" }); - return; - } - setQuery({ - kind: "error", - message: describeQueryError(cause), - retry: reload, - }); - }); - return () => { - cancelled = true; - }; - }, [tenantId, reloadKey]); - - // Connections can change elsewhere (another tab, the Plugins gallery's - // connect panel, or a credential expiring during a long agent run). - // Re-read on visibility/focus and poll while the page sits open so a - // "Connected" pill never lies silently — the same live-surface concern - // that `ConnectServiceBlockContainer` and `ConnectGithubBlockContainer` - // solve via `subscribeConnectState`. - useEffect(() => { - if (tenantId === null) return; - // `visibilitychange` and `focus` both fire in the same tick when a tab - // regains focus; the microtask guard collapses that pair into one - // scheduled bump instead of two back-to-back reloads. - let bumpScheduled = false; - const refreshWhenVisible = () => { - if (document.visibilityState !== "visible") return; - if (bumpScheduled) return; - bumpScheduled = true; - queueMicrotask(() => { - bumpScheduled = false; - setReloadKey((value) => value + 1); - }); - }; - document.addEventListener("visibilitychange", refreshWhenVisible); - window.addEventListener("focus", refreshWhenVisible); - const interval = setInterval(refreshWhenVisible, 30_000); - return () => { - document.removeEventListener("visibilitychange", refreshWhenVisible); - window.removeEventListener("focus", refreshWhenVisible); - clearInterval(interval); - }; - }, [tenantId]); - - if (tenantId === null) { - return ( - - ); - } - - const currentTenantId = tenantId; - - // The row list's and OAuth pair's disconnect action — a connector, not - // a raw credential, so it goes through `disconnectConnector`'s - // orchestrated cleanup (catalog provider, then credential provider — - // see that function's own header for why a direct credential delete - // 500s for an inference provider). - function handleDisconnectConnector(connectorId: string) { - setRowError(null); - disconnectConnector(currentTenantId, connectorId) - .then(() => { - reload(); - toast(SETTINGS_STRINGS.credentialRevokedToast); - }) - .catch(() => setRowError(SETTINGS_STRINGS.connectionsDisconnectError)); - } - - return ( - - {({ credentials, providers, oauthConfigured, models, ownOfferingIds }) => { - const effectiveRows = buildEffectiveInferenceRows(models, ownOfferingIds); - return ( - - {rowError !== null && ( -

- {rowError} -

- )} - -
-
- - - -
-

AI providers

-

Connect the services that can run your models.

-
-
-
- descriptor.feedsTools.length === 0} - onReload={reload} - onError={setRowError} - /> - {OAUTH_CARDS.map((card) => ( - row.providerName === card.id) - .map((row) => row.canonicalName), - ).size - } - configured={oauthConfigured[card.id] ?? false} - onDisconnect={() => handleDisconnectConnector(card.id)} - /> - ))} -
-
-
- ); - }} -
- ); -} - -function modelLabel(row: EffectiveInferenceRow): string { - return row.modelDisplayName ?? row.canonicalName; -} - -function ModelRoutePanel({ - tenantId, - rows, - onReload, - onError, -}: { - readonly tenantId: string; - readonly rows: readonly EffectiveInferenceRow[]; - readonly onReload: () => void; - readonly onError: (message: string | null) => void; -}) { - const ordered = orderedGlobalInferenceRows(rows); - const currentModel = ordered[0]?.canonicalName ?? ""; - const models = ordered.filter( - (row, index, all) => - all.findIndex((candidate) => candidate.canonicalName === row.canonicalName) === index, - ); - const route = ordered.filter((row) => row.canonicalName === currentModel); - - function applyPatches( - patches: readonly { readonly offeringId: string; readonly priority: number }[] | null, - ) { - if (patches === null) { - onError(SETTINGS_STRINGS.connectionsSetDefaultModelError); - return; - } - onError(null); - Promise.all( - patches.map((patch) => - updateOwnOffering(tenantId, patch.offeringId, { - priority: patch.priority, - }), - ), - ) - .then(onReload) - .catch(() => onError(SETTINGS_STRINGS.connectionsSetDefaultModelError)); - } - - function chooseModel(canonicalName: string) { - // Tenant default is an offering-priority write only. Existing - // agents keep whatever `capabilities.model` they already store; new - // agents may still pick up the default at create. Never PATCH - // `/agent-definitions`. - const target = ordered.find((row) => row.canonicalName === canonicalName); - applyPatches(target === undefined ? null : computeMakeDefaultPatches(rows, target.offeringId)); - } - - return ( -
-
- - - -
-

Default model & fallbacks

-

- Used by Myra and new agents. Changing the default does not rewrite models already stored - on existing agents. -

-
-
- {ordered.length === 0 ? ( -
- - Connect an AI provider to choose a default model. -
- ) : ( - <> - -
-

Fallback order

- {route.map((row, index) => ( -
- - {index === 0 ? "Primary" : `Fallback ${index}`} - - - {providerDisplayName(row.providerName)} - - - {row.provenance === "set-here" ? "Set here" : "Inherited"} - -
- - -
-
- ))} -
- - )} -
- ); -} - -function pinnedByLine(connectorId: string): string { - const assetNames = CONNECTOR_PINNED_WORKFLOWS[connectorId] ?? []; - if (assetNames.length === 0) return SETTINGS_STRINGS.connectionsPinnedByNone; - const names = assetNames.map((assetName) => workflowDisplayName(assetName)); - return `${SETTINGS_STRINGS.connectionsPinnedByPrefix}${names.join(", ")}`; -} - -/** Plain, uppercase status text — never a colored pill. Needs-attention is - * the one accent-colored state on this row (the owner's brand rule: grey - * for text/structure, the accent color only ever marks something to act - * on), matching the plugins directory's own `plugins-directory-needs- - * attention` convention. */ -function StatusCaption({ statusResult }: { readonly statusResult: ConnectorStatusResult }) { - if (statusResult.status === "connected") { - return ( - - {SETTINGS_STRINGS.connectionsStatusConnected} - - ); - } - if (statusResult.status === "needs_attention") { - return ( - - {SETTINGS_STRINGS.connectionsStatusNeedsAttention} - - ); - } - return ( - - {SETTINGS_STRINGS.connectionsStatusNotConnected} - - ); -} - -/** The row's brand mark: a connector's `simple-icons` path where the - * registry has one, a monochrome initial tile otherwise — the same tile - * pattern (zero radius, hairline border) the plugins directory's own - * `PluginLogo` uses (`packages/chat-ui/src/workbench-settings/plugins- - * section.tsx`), reused here rather than re-derived. */ -function ConnectorLogo({ - displayName, - icon, -}: { - readonly displayName: string; - readonly icon?: { - readonly path: string; - readonly hex: string; - readonly viewBox?: string; - }; -}) { - if (icon !== undefined) { - return ( - - ); - } - return ( - - ); -} - -function ConnectorRow({ - descriptor, - statusResult, - modelCount, - onConnect, - onReconnect, - onDisconnect, -}: { - readonly descriptor: ConnectorDescriptor; - readonly statusResult: ConnectorStatusResult; - readonly modelCount: number; - readonly onConnect: () => void; - readonly onReconnect: () => void; - readonly onDisconnect: () => void; -}) { - return ( -
- -
-
- {descriptor.displayName} - -
- {statusResult.status === "connected" && modelCount > 0 ? ( -

- {modelCount} model{modelCount === 1 ? "" : "s"} available -

- ) : null} - {descriptor.feedsTools.length > 0 && ( - - {pinnedByLine(descriptor.id)} - {(CONNECTOR_PINNED_WORKFLOWS[descriptor.id]?.length ?? 0) > 0 && ( - - )} - - )} -
-
- {statusResult.status === "connected" && ( - - {SETTINGS_STRINGS.connectionsDisconnectAction} - - )} - {statusResult.status === "not_connected" && ( - - )} - {statusResult.status === "needs_attention" && ( - - )} -
-
- ); -} - -function OAuthConnectorRow({ - tenantId, - card, - statusResult, - modelCount, - configured, - onDisconnect, -}: { - readonly tenantId: string; - readonly card: OAuthConnectorCard; - readonly statusResult: ConnectorStatusResult; - readonly modelCount: number; - /** Whether an operator has registered this connector's OAuth app - * (a client id present server-side) — distinct from `statusResult`, - * which is about whether *this tenant* has connected, not whether - * connecting is even possible yet. */ - readonly configured: boolean; - readonly onDisconnect: () => void; -}) { - const icon = CONNECTOR_REGISTRY[card.id]?.icon; - - // An unconfigured connector never gets a live Connect button, even - // when this tenant already holds a (now-orphaned) credential for it — - // there is no OAuth app to round-trip through until an operator - // registers one, so the muted state wins regardless of `statusResult`. - if (!configured) { - return ( -
- -
-
- {card.displayName} - - {SETTINGS_STRINGS.connectionsStatusNotConfigured} - -
-

- {SETTINGS_STRINGS.connectionsNotConfiguredHint} -

-
-
- ); - } - - return ( -
- -
-
- {card.displayName} - -
- {statusResult.status === "connected" && modelCount > 0 ? ( -

- {modelCount} model{modelCount === 1 ? "" : "s"} available -

- ) : null} -
-
- {statusResult.status === "connected" ? ( - - {SETTINGS_STRINGS.connectionsDisconnectAction} - - ) : ( - - )} -
-
- ); -} - -export function ConnectorCredentialDialog({ - descriptor, - mode, - tenantId, - onClose, - onConnected, -}: { - readonly descriptor: ConnectorDescriptor | null; - readonly mode: "connect" | "reconnect"; - readonly tenantId: string; - readonly onClose: () => void; - readonly onConnected: () => void; -}) { - const [apiKey, setApiKey] = useState(""); - const [showKey, setShowKey] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); - - const isUrlField = descriptor?.credentialInputKind === "url"; - - useEffect(() => { - setApiKey( - descriptor?.credentialInputKind === "url" ? (descriptor.credentialPlaceholder ?? "") : "", - ); - setShowKey(false); - setSubmitting(false); - setSubmitError(null); - }, [descriptor]); - - const open = descriptor !== null; - const canSubmit = apiKey.trim() !== "" && !submitting; - - // One action, not test-then-save: the server proves the key - // with a real call before ever storing it, so a rejected key never gets - // sealed — this call is the only round-trip, and its 422 rejection - // renders inline the same as any other connect failure. - function handleSubmit() { - if (descriptor === null) return; - setSubmitting(true); - setSubmitError(null); - completeConnectorCredential(tenantId, descriptor.id, apiKey) - .then((completed) => { - // A fresh Ollama connect with only an embedding model - // pulled still succeeds — `modelGuidance` says so in the same - // consumer language the "connected" toast normally would, - // instead of the generic success line. - toast( - completed.modelGuidance ?? - SETTINGS_STRINGS.connectionsConnectedToast(descriptor.displayName), - ); - onConnected(); - }) - .catch((cause: unknown) => { - setSubmitError( - cause instanceof ConnectionsApiError && cause.status === 422 - ? cause.message - : describeQueryError(cause), - ); - }) - .finally(() => setSubmitting(false)); - } - - return ( - { - if (!next) onClose(); - }} - > - - - - {descriptor === null - ? "" - : mode === "reconnect" - ? SETTINGS_STRINGS.connectionsDialogReconnectTitle(descriptor.displayName) - : SETTINGS_STRINGS.connectionsDialogConnectTitle(descriptor.displayName)} - - - {isUrlField - ? SETTINGS_STRINGS.connectionsDialogUrlDescription - : SETTINGS_STRINGS.connectionsDialogDescription} - - - -
- - {isUrlField - ? SETTINGS_STRINGS.connectionsUrlLabel - : SETTINGS_STRINGS.connectionsKeyLabel} - - {isUrlField ? ( - { - setApiKey(event.target.value); - setSubmitError(null); - }} - autoComplete="off" - /> - ) : ( -
- { - setApiKey(event.target.value); - setSubmitError(null); - }} - autoComplete="off" - /> - -
- )} -
- {submitError !== null && ( -

- {submitError} -

- )} -
- - - - -
-
- ); -} diff --git a/apps/web/src/settings/connections-status.test.ts b/apps/web/src/settings/connections-status.test.ts deleted file mode 100644 index 124af73ea7..0000000000 --- a/apps/web/src/settings/connections-status.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { connectorStatus } from "./connections-status"; -import type { Credential, Provider } from "./credentials-api"; - -// The backend always names a provider row after the connector's -// lowercase id (`ensureProvider({ name: descriptor.id, ... })`) — never -// its display label — so every fixture here mirrors that: "linear", not -// "Linear". -function provider(overrides: Partial = {}): Provider { - return { - id: "provider-1", - name: "linear", - plugin: "linear", - ...overrides, - } as Provider; -} - -function credential(overrides: Partial = {}): Credential { - return { - id: "cred-1", - tenantId: "tenant-1", - providerId: "provider-1", - name: "Linear", - type: "api_key", - status: "active", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - ...overrides, - } as Credential; -} - -describe("connectorStatus", () => { - test("not_connected when no provider matches the connector's id", () => { - const result = connectorStatus("linear", [], []); - expect(result.status).toBe("not_connected"); - }); - - test("connected when a provider exists even if the credential list omits inherited credentials", () => { - const result = connectorStatus("linear", [], [provider()]); - expect(result.status).toBe("connected"); - }); - - test("connected when local Ollama has an active credential", () => { - const result = connectorStatus( - "ollama", - [credential({ providerId: "ollama-provider", status: "active" })], - [provider({ id: "ollama-provider", name: "ollama", plugin: "ollama" })], - ); - expect(result.status).toBe("connected"); - }); - - test("connected when the newest credential is active", () => { - const result = connectorStatus("linear", [credential({ status: "active" })], [provider()]); - expect(result.status).toBe("connected"); - }); - - test("needs_attention when the newest credential is expired", () => { - const result = connectorStatus("linear", [credential({ status: "expired" })], [provider()]); - expect(result.status).toBe("needs_attention"); - }); - - test("needs_attention when the newest credential is in error", () => { - const result = connectorStatus("linear", [credential({ status: "error" })], [provider()]); - expect(result.status).toBe("needs_attention"); - }); - - test("not_connected when the newest credential is revoked", () => { - const result = connectorStatus("linear", [credential({ status: "revoked" })], [provider()]); - expect(result.status).toBe("not_connected"); - }); - - test("picks the credential with the newest createdAt when more than one exists", () => { - const older = credential({ - id: "cred-old", - status: "expired", - createdAt: "2026-01-01T00:00:00.000Z", - }); - const newer = credential({ - id: "cred-new", - status: "active", - createdAt: "2026-02-01T00:00:00.000Z", - }); - const result = connectorStatus("linear", [older, newer], [provider()]); - expect(result.status).toBe("connected"); - if (result.status === "connected") { - expect(result.credential?.id).toBe("cred-new"); - } - }); - - test("keeps the provider connected when only unrelated credentials are listed", () => { - const other = credential({ providerId: "provider-2", status: "active" }); - const result = connectorStatus("linear", [other], [provider()]); - expect(result.status).toBe("connected"); - }); - - // Regression for the bug where the card grid matched on - // `descriptor.displayName` ("Linear", "Ollama") against a provider row - // the backend always names by lowercase id ("linear", "ollama") — every - // card read "not connected" forever, no matter how many times a person - // reconnected. A caller passing the display label instead of the id - // must never match. - test("a connector's display name never matches its own provider row", () => { - const result = connectorStatus("Linear", [credential({ status: "active" })], [provider()]); - expect(result.status).toBe("not_connected"); - }); -}); diff --git a/apps/web/src/settings/connections-status.ts b/apps/web/src/settings/connections-status.ts deleted file mode 100644 index 1a956a1ddf..0000000000 --- a/apps/web/src/settings/connections-status.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Pure status derivation for a connector card: cross-references a -// `ConnectorDescriptor` against the tenant's already-fetched credentials -// and providers (no new fetch — see connections-section.tsx). - -import type { Credential, Provider } from "./credentials-api"; - -export type ConnectorStatus = "not_connected" | "connected" | "needs_attention"; - -export type ConnectorStatusResult = - | { readonly status: "not_connected" } - | { readonly status: "connected"; readonly credential?: Credential } - | { readonly status: "needs_attention"; readonly credential: Credential }; - -/** - * The backend names a connector's provider row after the connector's - * lowercase `id` (see `packages/connections/src/routes.ts`'s and - * `packages/connections/src/seed-catalog.ts`'s own `ensureProvider` calls, - * both of which pass `descriptor.id`/`seed.provider.name`, never a - * display label) — matching on that id is how a card finds its provider - * without a new list route. `displayName` is UI-only and never reaches - * a provider row, so matching on it here would leave every card reading - * "not connected" forever. If a tenant reconnected more than once, more - * than one credential can point at the same provider; the tiebreak here - * is newest `createdAt` (falling back to `updatedAt` for a tie) — "most - * recently created" reads as "the connection currently in effect" better - * than "most recently touched," since a stale credential can still be - * touched by an unrelated update. - */ -export function connectorStatus( - connectorId: string, - credentials: readonly Credential[], - providers: readonly Provider[], -): ConnectorStatusResult { - const provider = providers.find((p) => p.name === connectorId); - if (provider === undefined) return { status: "not_connected" }; - - const matches = credentials.filter((c) => c.providerId === provider.id); - if (matches.length === 0) return { status: "connected" }; - - const newest = matches.reduce((latest, candidate) => { - const latestKey = latest.createdAt ?? latest.updatedAt; - const candidateKey = candidate.createdAt ?? candidate.updatedAt; - return candidateKey > latestKey ? candidate : latest; - }); - - if (newest.status === "active") { - return { status: "connected", credential: newest }; - } - if (newest.status === "expired" || newest.status === "error") { - return { status: "needs_attention", credential: newest }; - } - // revoked reads the same as never having connected — nothing to attend - // to, the user must reconnect from scratch. - return { status: "not_connected" }; -} diff --git a/apps/web/src/settings/credentials-api.ts b/apps/web/src/settings/credentials-api.ts index e396e19638..b047f022c1 100644 --- a/apps/web/src/settings/credentials-api.ts +++ b/apps/web/src/settings/credentials-api.ts @@ -8,6 +8,7 @@ import { paginatedSchema, type CredentialType, } from "@intx/types"; +export { credentialTypes } from "@intx/types"; import { apiRequest, type Validator } from "./api-request"; @@ -49,6 +50,18 @@ export function listProviders(tenantId: string): Promise { ); } +/** Mints a plain, non-inference provider row named after the credential + * a person is about to store — the stock credentials route requires a + * `providerId`, and a bare "add a key" form has no provider of its own + * to point at yet. `plugin: "custom"` marks it as this form's own, + * generic kind rather than an inference adapter. */ +export function createProvider(tenantId: string, name: string): Promise { + return request(`/api/tenants/${tenantId}/providers`, ProviderResponse, "creating that provider", { + method: "POST", + body: JSON.stringify({ name, plugin: "custom" }), + }); +} + export type CreateCredentialInput = { readonly providerId: string; readonly name: string; diff --git a/apps/web/src/settings/credentials-section.tsx b/apps/web/src/settings/credentials-section.tsx new file mode 100644 index 0000000000..25f4e945f6 --- /dev/null +++ b/apps/web/src/settings/credentials-section.tsx @@ -0,0 +1,292 @@ +// The "Credentials" settings section: a plain list-plus-create surface +// over Interchange's own stock credential routes +// (`vendor/intx/hub-api/src/routes/credentials.ts`) — the only way a key +// gets stored in this repo. No connector registry, no OAuth connect +// flow, no per-provider card: just the credentials this bench owns, +// named and typed, with a form to add one and a button to delete it. + +import { + Button, + ConfirmButton, + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + EmptyState, + Input, + SettingsPanel, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@corbits/react-ui"; +import type { CredentialType } from "@intx/types"; +import { useEffect, useState } from "react"; + +import type { APIQuery } from "@/lib/api-query"; +import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query"; +import { + createCredential, + createProvider, + credentialTypes, + deleteCredential, + listCredentials, + listProviders, + type Credential, + type Provider, +} from "./credentials-api"; +import { SETTINGS_STRINGS } from "./strings"; + +export function CredentialsSection({ tenantId }: { readonly tenantId: string | null }) { + const [query, setQuery] = useState>({ kind: "loading" }); + const [reloadKey, setReloadKey] = useState(0); + const [createOpen, setCreateOpen] = useState(false); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + const [rowError, setRowError] = useState(null); + const [providers, setProviders] = useState([]); + + function reload() { + setReloadKey((value) => value + 1); + } + + useEffect(() => { + if (tenantId === null) return; + let cancelled = false; + setQuery({ kind: "loading" }); + Promise.all([listCredentials(tenantId), listProviders(tenantId)]) + .then(([credentials, providerRows]) => { + if (cancelled) return; + setProviders(providerRows); + setQuery({ kind: "ready", data: credentials }); + }) + .catch((cause: unknown) => { + if (cancelled) return; + if (cause instanceof UnauthenticatedError) { + setQuery({ kind: "unauthenticated" }); + return; + } + setQuery({ kind: "error", message: describeQueryError(cause), retry: reload }); + }); + return () => { + cancelled = true; + }; + }, [tenantId, reloadKey]); + + if (tenantId === null) { + return ( + + ); + } + + function handleCreate(name: string, type: CredentialType, secret: string) { + if (tenantId === null) return; + setCreating(true); + setCreateError(null); + const existing = providers.find((provider) => provider.name === name); + (existing !== undefined ? Promise.resolve(existing) : createProvider(tenantId, name)) + .then((provider) => + createCredential(tenantId, { providerId: provider.id, name, type, secret }), + ) + .then(() => { + setCreateOpen(false); + reload(); + }) + .catch(() => setCreateError(SETTINGS_STRINGS.credentialsCreateError)) + .finally(() => setCreating(false)); + } + + function handleDelete(credential: Credential) { + if (tenantId === null) return; + setRowError(null); + deleteCredential(tenantId, credential.id) + .then(reload) + .catch(() => setRowError(SETTINGS_STRINGS.credentialsDeleteError)); + } + + return ( + + {(credentials) => ( + +
+ +
+ {rowError !== null && ( +

+ {rowError} +

+ )} + + +
+ )} +
+ ); +} + +function CredentialsTable({ + credentials, + onDelete, +}: { + readonly credentials: readonly Credential[]; + readonly onDelete: (credential: Credential) => void; +}) { + if (credentials.length === 0) { + return ( + + ); + } + return ( + + + + {SETTINGS_STRINGS.credentialsNameLabel} + {SETTINGS_STRINGS.credentialsTypeLabel} + {SETTINGS_STRINGS.credentialsStatusLabel} + Actions + + + + {credentials.map((credential) => ( + + {credential.name} + {credential.type} + {credential.status} + + onDelete(credential)} + > + {SETTINGS_STRINGS.credentialsDeleteAction} + + + + ))} + +
+ ); +} + +function CreateCredentialDialog({ + open, + onOpenChange, + onCreate, + submitting, + error = null, +}: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onCreate: (name: string, type: CredentialType, secret: string) => void; + readonly submitting: boolean; + readonly error?: string | null; +}) { + const [name, setName] = useState(""); + const [type, setType] = useState("api_key"); + const [secret, setSecret] = useState(""); + const canSubmit = name.trim().length > 0 && secret.trim().length > 0; + + function reset() { + setName(""); + setType("api_key"); + setSecret(""); + } + + return ( + { + onOpenChange(next); + if (!next) reset(); + }} + > + + + {SETTINGS_STRINGS.credentialsCreateDialogTitle} + + {SETTINGS_STRINGS.credentialsCreateDialogDescription} + + + +
{ + event.preventDefault(); + if (canSubmit) onCreate(name.trim(), type, secret); + }} + > + + + + {error !== null && ( +

+ {error} +

+ )} +
+
+ + + + +
+
+ ); +} diff --git a/apps/web/src/settings/granola-webhook-api.ts b/apps/web/src/settings/granola-webhook-api.ts deleted file mode 100644 index 00099c30fe..0000000000 --- a/apps/web/src/settings/granola-webhook-api.ts +++ /dev/null @@ -1,165 +0,0 @@ -// The Granola webhook card's seam to `@corbits/webhook-triggers`' -// management routes (mint/rotate a signed inbound address). Nothing here -// adds a route — listings, create, and rotate already exist; this module -// only shapes the fetch calls, `connections-api.ts`-style: same wrapper, -// same error class convention, arktype at the trust boundary. -// -// `packages/settings-ui` never imports `apps/web/src/webhook-triggers-api.ts` -// — apps depend on packages, never the reverse — so this duplicates the -// same thin-client shape that module already uses, trimmed to only the -// fields the Granola card reads. -// -// A webhook trigger's secret is never stored beyond the component state -// that shows it once: the hub returns it only on create or rotate, never -// on a list/get read. See `packages/webhook-triggers/src/management-routes.ts`. - -import { type } from "arktype"; - -import { apiRequest, type Validator } from "./api-request"; - -export class GranolaWebhookApiError extends Error { - constructor( - message: string, - readonly status?: number, - ) { - super(message); - } -} - -function request( - path: string, - schema: Validator, - verb: string, - init?: RequestInit, -): Promise { - return apiRequest(path, schema, verb, GranolaWebhookApiError, init); -} - -const WebhookTriggerFields = { - id: "string", - name: "string", - workflowDefinitionId: "string", - enabled: "boolean", - createdAt: "string", - lastFiredAt: "string | null", -} as const; - -export const GranolaWebhookTrigger = type(WebhookTriggerFields); -export type GranolaWebhookTrigger = typeof GranolaWebhookTrigger.infer; - -const GranolaWebhookTriggerWithSecret = type({ - ...WebhookTriggerFields, - secret: "string", -}); -export type GranolaWebhookTriggerWithSecret = typeof GranolaWebhookTriggerWithSecret.infer; - -export function listGranolaWebhookTriggers( - tenantId: string, -): Promise { - return request( - `/api/tenants/${tenantId}/webhook-triggers`, - type({ items: GranolaWebhookTrigger.array() }), - "loading webhooks", - ).then((page) => page.items); -} - -/** Default input mapping — a webhook trigger sends a fixed - * message rather than an editable template; see - * `packages/webhook-triggers/src/mapping.ts` and - * `apps/web/src/webhook-triggers-api.ts`'s identical constant. */ -const DEFAULT_WEBHOOK_INPUT_TEMPLATE = "New webhook delivery."; - -export function createGranolaWebhookTrigger( - tenantId: string, - workflowDefinitionId: string, - name: string, -): Promise { - return request( - `/api/tenants/${tenantId}/webhook-triggers`, - GranolaWebhookTriggerWithSecret, - "creating that webhook", - { - method: "POST", - body: JSON.stringify({ - name, - workflowDefinitionId, - inputTemplate: DEFAULT_WEBHOOK_INPUT_TEMPLATE, - }), - }, - ); -} - -export function rotateGranolaWebhookTriggerSecret( - tenantId: string, - id: string, -): Promise { - return request( - `/api/tenants/${tenantId}/webhook-triggers/${id}/rotate-secret`, - GranolaWebhookTriggerWithSecret, - "rotating that secret", - { method: "POST", body: JSON.stringify({}) }, - ); -} - -/** The URL a sender posts deliveries to — a pure function of the trigger - * id and origin, matching `POST /api/webhooks/:triggerId` - * (`packages/webhook-triggers/src/ingress-routes.ts`). No route returns - * it; every caller builds it client-side. */ -export function webhookTriggerUrl(triggerId: string): string { - const origin = typeof window !== "undefined" ? window.location.origin : ""; - return `${origin}/api/webhooks/${triggerId}`; -} - -/** An illustrative example of a POST body Granola might send — pure - * documentation, matching `webhook-triggers-api.ts`'s - * `sampleWebhookPayload`. `@corbits/webhook-triggers` places no - * constraint on payload shape beyond "valid JSON". */ -export function sampleGranolaWebhookPayload(): string { - return JSON.stringify({ event: "call.completed", data: { callId: "call_123" } }, null, 2); -} - -const WorkflowDefinitionSummary = type({ - id: "string", - name: "string", -}); -export type WorkflowDefinitionSummary = typeof WorkflowDefinitionSummary.infer; - -const WorkflowDefinitionRecord = type({ - id: "string", - name: "string", - status: "string", - "description?": "string | null", -}); - -const DefinitionsPage = type({ - data: WorkflowDefinitionRecord.array(), - "nextCursor?": "string | null", -}); - -const PAGE_LIMIT = 100; - -/** - * Every workflow definition on the tenant, walking pagination, reduced to - * `{id, name}` — enough to find which definition id(s) belong to the - * `granola-call` asset. Walks the platform's raw definitions listing - * without extra filtering, which the card doesn't need. - */ -export async function listGranolaWorkflowDefinitions( - tenantId: string, -): Promise { - const collected: WorkflowDefinitionSummary[] = []; - let cursor: string | null = null; - for (;;) { - const query = new URLSearchParams({ limit: String(PAGE_LIMIT) }); - if (cursor !== null) query.set("cursor", cursor); - const page = await request( - `/api/tenants/${tenantId}/workflows/definitions?${query}`, - DefinitionsPage, - "loading workflow definitions", - ); - collected.push(...page.data.map(({ id, name }) => ({ id, name }))); - if (page.nextCursor === undefined || page.nextCursor === null) break; - cursor = page.nextCursor; - } - return collected; -} diff --git a/apps/web/src/settings/granola-webhook-card.tsx b/apps/web/src/settings/granola-webhook-card.tsx deleted file mode 100644 index f5b97c8ef7..0000000000 --- a/apps/web/src/settings/granola-webhook-card.tsx +++ /dev/null @@ -1,366 +0,0 @@ -// The Granola inbound-webhook connector card. Workbench is the -// secret *issuer* here — the reverse of the `granola` api-key connector -// above it in the grid, which stores a key Granola issued. This card -// surfaces `@corbits/webhook-triggers`' existing mint/rotate machinery -// against the tenant's `granola-call` workflow definition and its -// webhook triggers: no new secret code, no new backend route, just the -// Connections-surface view of an existing binding. -// -// "Granola-ish" means bound to the `granola-call` workflow definition -// specifically — the one automatable Granola workflow in -// `@workbench/templates` (`process-granola-call` is a child run, -// never a routine). A routine picker is deliberately not built: with -// exactly one candidate workflow, offering a picker over a list of one -// would be UI theater, not a real choice. - -import { - Button, - Dialog, - DialogBody, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - formatRelativeTime, -} from "@corbits/react-ui"; -import type { ConnectorDescriptor } from "@corbits/connections/registry"; -import { useEffect, useRef, useState } from "react"; - -import type { APIQuery } from "@/lib/api-query"; -import { QueryView, UnauthenticatedError, describeQueryError } from "@/lib/api-query"; -import { - createGranolaWebhookTrigger, - listGranolaWebhookTriggers, - listGranolaWorkflowDefinitions, - rotateGranolaWebhookTriggerSecret, - sampleGranolaWebhookPayload, - webhookTriggerUrl, - type GranolaWebhookTrigger, - type WorkflowDefinitionSummary, -} from "./granola-webhook-api"; -import { SETTINGS_STRINGS } from "./strings"; -import { CopyableCodeRow, WebhookSecretPanel } from "./webhook-secret-panel"; - -/** The one automatable Granola workflow — see this file's header comment. */ -const GRANOLA_WORKFLOW_ASSET_NAME = "granola-call"; - -type GranolaBinding = { - readonly definition: WorkflowDefinitionSummary; - readonly webhookTrigger: GranolaWebhookTrigger | null; -}; - -type GranolaWebhookData = { - readonly bindings: readonly GranolaBinding[]; -}; - -function boundTriggers(bindings: readonly GranolaBinding[]): readonly GranolaWebhookTrigger[] { - return bindings - .map((binding) => binding.webhookTrigger) - .filter((trigger): trigger is GranolaWebhookTrigger => trigger !== null); -} - -function mostRecentFiredAt(triggers: readonly GranolaWebhookTrigger[]): string | null { - const fired = triggers - .map((trigger) => trigger.lastFiredAt) - .filter((value): value is string => value !== null) - .sort(); - return fired.length === 0 ? null : (fired[fired.length - 1] ?? null); -} - -async function loadGranolaWebhookData(tenantId: string): Promise { - const [definitions, triggers] = await Promise.all([ - listGranolaWorkflowDefinitions(tenantId), - listGranolaWebhookTriggers(tenantId), - ]); - const granolaDefinitions = definitions.filter( - (definition) => definition.name === GRANOLA_WORKFLOW_ASSET_NAME, - ); - const bindings = granolaDefinitions.map((definition) => ({ - definition, - webhookTrigger: - triggers.find((trigger) => trigger.workflowDefinitionId === definition.id) ?? null, - })); - return { bindings }; -} - -export function GranolaWebhookCard({ - tenantId, - descriptor, -}: { - readonly tenantId: string; - readonly descriptor: ConnectorDescriptor; -}) { - const [query, setQuery] = useState>({ - kind: "loading", - }); - const [reloadKey, setReloadKey] = useState(0); - const [dialogOpen, setDialogOpen] = useState(false); - // A reload after a create/rotate refreshes the card's data in the - // background — it must never flip the tree back through "loading", - // which would unmount `GranolaWebhookDialog` and lose the just-revealed - // secret it's showing. - const loadedOnceRef = useRef(false); - - useEffect(() => { - loadedOnceRef.current = false; - }, [tenantId]); - - function reload() { - setReloadKey((value) => value + 1); - } - - useEffect(() => { - let cancelled = false; - if (!loadedOnceRef.current) setQuery({ kind: "loading" }); - loadGranolaWebhookData(tenantId) - .then((data) => { - if (cancelled) return; - loadedOnceRef.current = true; - setQuery({ kind: "ready", data }); - }) - .catch((cause: unknown) => { - if (cancelled) return; - if (cause instanceof UnauthenticatedError) { - setQuery({ kind: "unauthenticated" }); - return; - } - setQuery({ - kind: "error", - message: describeQueryError(cause), - retry: reload, - }); - }); - return () => { - cancelled = true; - }; - }, [tenantId, reloadKey]); - - return ( - - {({ bindings }) => { - const triggers = boundTriggers(bindings); - const connected = triggers.length > 0; - const lastFiredAt = mostRecentFiredAt(triggers); - return ( - <> -
- -
-
- {descriptor.displayName} - - {connected - ? SETTINGS_STRINGS.connectionsStatusConnected - : SETTINGS_STRINGS.connectionsWebhookNotSetUp} - -
- {connected && ( -

- {SETTINGS_STRINGS.connectionsWebhookTriggerCount(triggers.length)} ·{" "} - {lastFiredAt !== null - ? SETTINGS_STRINGS.connectionsWebhookLastDelivery( - formatRelativeTime(lastFiredAt), - ) - : SETTINGS_STRINGS.connectionsWebhookNoDeliveries} -

- )} -

- {SETTINGS_STRINGS.connectionsWebhookDirectionNote} -

-
-
- -
-
- setDialogOpen(false)} - onChanged={reload} - /> - - ); - }} -
- ); -} - -type RevealedSecret = { - readonly definitionId: string; - readonly url: string; - readonly secret: string; -}; - -function GranolaWebhookDialog({ - open, - tenantId, - bindings, - onClose, - onChanged, -}: { - readonly open: boolean; - readonly tenantId: string; - readonly bindings: readonly GranolaBinding[]; - readonly onClose: () => void; - readonly onChanged: () => void; -}) { - const [revealed, setRevealed] = useState(null); - const [busyDefinitionId, setBusyDefinitionId] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - if (!open) { - setRevealed(null); - setError(null); - setBusyDefinitionId(null); - } - }, [open]); - - function handleCreate(definition: WorkflowDefinitionSummary) { - setBusyDefinitionId(definition.id); - setError(null); - createGranolaWebhookTrigger(tenantId, definition.id, definition.name) - .then((created) => { - setRevealed({ - definitionId: definition.id, - url: webhookTriggerUrl(created.id), - secret: created.secret, - }); - onChanged(); - }) - .catch((cause: unknown) => setError(describeQueryError(cause))) - .finally(() => setBusyDefinitionId(null)); - } - - function handleRotate(binding: GranolaBinding) { - const webhookTrigger = binding.webhookTrigger; - if (webhookTrigger === null) return; - setBusyDefinitionId(binding.definition.id); - setError(null); - rotateGranolaWebhookTriggerSecret(tenantId, webhookTrigger.id) - .then((rotated) => { - setRevealed({ - definitionId: binding.definition.id, - url: webhookTriggerUrl(rotated.id), - secret: rotated.secret, - }); - onChanged(); - }) - .catch((cause: unknown) => setError(describeQueryError(cause))) - .finally(() => setBusyDefinitionId(null)); - } - - return ( - { - if (!next) onClose(); - }} - > - - - {SETTINGS_STRINGS.connectionsWebhookDialogTitle} - - {SETTINGS_STRINGS.connectionsWebhookDialogDescription} - - - - {bindings.length === 0 ? ( -

{SETTINGS_STRINGS.connectionsWebhookNoRoutine}

- ) : ( - bindings.map((binding) => ( -
-
- {binding.definition.name} - {binding.webhookTrigger !== null && ( - - {binding.webhookTrigger.lastFiredAt !== null - ? SETTINGS_STRINGS.connectionsWebhookLastDelivery( - formatRelativeTime(binding.webhookTrigger.lastFiredAt), - ) - : SETTINGS_STRINGS.connectionsWebhookNoDeliveries} - - )} -
- {revealed?.definitionId === binding.definition.id ? ( - - ) : binding.webhookTrigger === null ? ( - - ) : ( -
- -
- - {SETTINGS_STRINGS.connectionsWebhookSigningSecretLabel} - -

- {SETTINGS_STRINGS.connectionsWebhookHiddenSecretNote} -

-
-
- -
-
- )} -
- )) - )} - {error !== null && ( -

- {error} -

- )} -
- - - -
-
- ); -} diff --git a/apps/web/src/settings/index.ts b/apps/web/src/settings/index.ts index 74c7385c11..3e70c265e4 100644 --- a/apps/web/src/settings/index.ts +++ b/apps/web/src/settings/index.ts @@ -15,14 +15,7 @@ export { AuditSection } from "./audit-section"; export { PeopleSection, PeopleTable } from "./people-section"; export { RolesSection, RolesTable, RoleAssignments, CreateRoleDialog } from "./roles-section"; export { GrantsSection, GrantsTable, CreateGrantDialog } from "./grants-section"; -export { - ConnectionsSection, - ConnectorRowList, - ConnectorCredentialDialog, - oauthStartHref, -} from "./connections-section"; - -export { GranolaWebhookCard } from "./granola-webhook-card"; +export { CredentialsSection } from "./credentials-section"; export { CopyButton, CopyableCodeRow, WebhookSecretPanel } from "./webhook-secret-panel"; @@ -62,23 +55,12 @@ export { CredentialsApiError, listCredentials, listProviders, + createProvider, createCredential, deleteCredential, } from "./credentials-api"; export type { Credential, Provider, CreateCredentialInput } from "./credentials-api"; -export { - ConnectionsApiError, - completeConnectorCredential, - disconnectConnector, - fetchOAuthConfigured, -} from "./connections-api"; - -export { connectorStatus } from "./connections-status"; -export type { ConnectorStatus, ConnectorStatusResult } from "./connections-status"; - -export { CONNECTOR_PINNED_WORKFLOWS } from "./connections-pinned-by"; - export { SETTINGS_STRINGS } from "./strings"; export { SettingsApiError, getAccount, renameBench } from "./api"; diff --git a/apps/web/src/settings/inference/api.ts b/apps/web/src/settings/inference/api.ts index 583c28868a..29e5b222cc 100644 --- a/apps/web/src/settings/inference/api.ts +++ b/apps/web/src/settings/inference/api.ts @@ -257,8 +257,7 @@ export type ShadowOfferingInput = { * module doc for why that is not even reachable from here), then a * tenant-local model and model-provider that reference it, then the * offering itself. Each step tolerates the row already existing - * (idempotent, mirroring `@corbits/connections`' `seed-catalog` helpers) - * so retrying a partial failure never 409s the whole flow. + * (idempotent) so retrying a partial failure never 409s the whole flow. * * Order matters beyond idempotency: creating this tenant's model-provider * row is the moment every offering this tenant can already see under that diff --git a/apps/web/src/settings/inference/effective-list.ts b/apps/web/src/settings/inference/effective-list.ts index ec9df16e73..67854f6c3d 100644 --- a/apps/web/src/settings/inference/effective-list.ts +++ b/apps/web/src/settings/inference/effective-list.ts @@ -5,25 +5,9 @@ // a plain unit test, no DOM or network stub required. import type { Capability, ModelInfo, ModelOfferingResponse } from "@intx/types"; -import { PROVIDER_TEST_CONFIG } from "@corbits/connections/credential-test"; -import { preferCompletionCapable } from "@corbits/connections/model-capability"; +import { preferCompletionCapable } from "./model-capability"; -/** - * The provider's own display name (e.g. "Ollama (local)", "Opencode Zen") - * for a catalog row's `providerName`, which is the internal slug the - * catalog stores it under (e.g. `"ollama"`, `"opencode-zen"`) — never a - * name a person should read as-is. Falls back to the raw slug for a - * provider `PROVIDER_TEST_CONFIG` doesn't know about (a custom - * bring-your-own-key provider a workbench minted under a name of its own - * choosing), so an unrecognized provider still renders something rather - * than nothing. - */ -export function providerDisplayName(providerName: string): string { - const config = (PROVIDER_TEST_CONFIG as Readonly>)[ - providerName - ]; - return config?.displayName ?? providerName; -} +export { providerDisplayName } from "./model-capability"; export type DefaultProviderModel = { readonly canonicalName: string; diff --git a/apps/web/src/settings/inference/model-capability.ts b/apps/web/src/settings/inference/model-capability.ts new file mode 100644 index 0000000000..b265b7088e --- /dev/null +++ b/apps/web/src/settings/inference/model-capability.ts @@ -0,0 +1,88 @@ +// Pure, presentation-only inference-catalog helpers the Inference section +// needs and no stock route provides: a provider slug's display name, and +// which offerings are safe to show in a person-facing chat picker. + +const PROVIDER_DISPLAY_NAMES: Readonly> = { + anthropic: "Anthropic", + openai: "OpenAI", + "google-genai": "Google", + xai: "xAI", + "xai-oauth": "xAI (Grok OAuth)", + codex: "Codex", + openrouter: "OpenRouter", + "opencode-zen": "Opencode Zen", + groq: "Groq", + deepseek: "DeepSeek", + mistral: "Mistral", + huggingface: "Hugging Face", + ollama: "Ollama (local)", +}; + +/** + * The provider's own display name (e.g. "Ollama (local)", "Opencode Zen") + * for a catalog row's `providerName`, which is the internal slug the + * catalog stores it under (e.g. `"ollama"`, `"opencode-zen"`) — never a + * name a person should read as-is. Falls back to the raw slug for a + * provider this map doesn't know about (a custom bring-your-own-key + * provider a workbench minted under a name of its own choosing), so an + * unrecognized provider still renders something rather than nothing. + */ +export function providerDisplayName(providerName: string): string { + return PROVIDER_DISPLAY_NAMES[providerName] ?? providerName; +} + +// A catalog offering minted from a live Ollama connect carries no +// capability metadata at all, so every pulled model (chat and embedding +// alike) becomes an offering tied at the same priority. This name-based +// fallback recognizes common embedding-model name families by convention +// whenever real, wire-observed capability data is unavailable. +const EMBEDDING_MODEL_NAME_PATTERN = + /(^|[-_/])embed(ding)?|(^|[-_/])(minilm|bge|gte|e5|arctic-embed)(-|_|:|$)/i; + +const HUGGING_FACE_OR_GGUF_NAME_PATTERN = /(^|\/)(hf\.co|huggingface\.co)\//i; + +function isEmbeddingModelName(canonicalName: string): boolean { + return EMBEDDING_MODEL_NAME_PATTERN.test(canonicalName); +} + +/** Ollama's Hugging Face Hub pull form (`hf.co/org/repo[:quant]`) or a + * bare `.gguf` path/tag — never offered in a person-facing chat picker. */ +export function isGgufOrHuggingFacePath(canonicalName: string): boolean { + const lower = canonicalName.toLowerCase(); + return lower.includes(".gguf") || HUGGING_FACE_OR_GGUF_NAME_PATTERN.test(canonicalName); +} + +/** + * Name-only chat-picker gate for catalog rows that carry no offering + * capability data. Drops embedding-named models, GGUF paths, and Hugging + * Face URIs. Prefer {@link preferCompletionCapable} when real capability + * lists are available. + */ +export function isChatPickerModelName(canonicalName: string): boolean { + return !isGgufOrHuggingFacePath(canonicalName) && !isEmbeddingModelName(canonicalName); +} + +function isCompletionCapable(capabilities: readonly string[], canonicalName: string): boolean { + if (isGgufOrHuggingFacePath(canonicalName)) return false; + return capabilities.length > 0 + ? capabilities.includes("plain-text") + : !isEmbeddingModelName(canonicalName); +} + +/** + * Narrows `offerings` to the completion-capable ones: whichever of + * `capabilitiesOf`'s real, wire-observed data or (when that's empty, + * uncataloged) `isEmbeddingModelName`'s name check says so. Also drops + * Hugging Face / GGUF path names. Returns an empty list when every + * offering is excluded — an embedding model must never win default-model + * resolution by default. + */ +export function preferCompletionCapable( + offerings: readonly T[], + capabilitiesOf: (offering: T) => readonly string[], + canonicalNameOf: (offering: T) => string, +): readonly T[] { + return offerings.filter((offering) => + isCompletionCapable(capabilitiesOf(offering), canonicalNameOf(offering)), + ); +} diff --git a/apps/web/src/settings/section-registry.tsx b/apps/web/src/settings/section-registry.tsx index 46fb4a1977..3ec1c3659f 100644 --- a/apps/web/src/settings/section-registry.tsx +++ b/apps/web/src/settings/section-registry.tsx @@ -5,7 +5,7 @@ // group and one shared group — there is one workbench per account now, so // a "workspace-scoped" setting and an "account-scoped" one are the same // tenant's settings. Shared Settings is the multiplayer-sharing surface: -// it leads with what everyone inherits (shared keys/connections, then +// it leads with what everyone inherits (shared credentials, then // People), and tucks the access-control mechanics (Roles, Grants, Audit) // under a collapsed Advanced disclosure — nobody should have to parse // grants and roles just to find where a shared API key lives. Bench dies @@ -23,7 +23,7 @@ import { Key, ListBullets, Shield, Star, User, Users } from "@/lib/icons"; import { AccountSection } from "./account-section"; import type { TenancyAccess } from "./access"; import { AuditSection } from "./audit-section"; -import { ConnectionsSection } from "./connections-section"; +import { CredentialsSection } from "./credentials-section"; import { GrantsSection } from "./grants-section"; import { PeopleSection } from "./people-section"; import { RolesSection } from "./roles-section"; @@ -68,17 +68,15 @@ const SETTINGS_SECTION_GROUPS: readonly SettingsSectionGroupDef[] = [ label: SETTINGS_STRINGS.groupEveryoneLabel, sections: [ { - // Plugins (`/plugins`) is the canonical surface for discovering - // and connecting a key; this section is management-only for keys - // that already exist (rotate, name, revoke) — see connections- - // section.tsx and the audit this reorganization follows. // Leads Shared Settings: a key added here is the thing everyone - // creating workbenches in this tenancy inherits. - id: "connections", - title: SETTINGS_STRINGS.connectionsSectionTitle, + // creating workbenches in this tenancy inherits. A plain list + // and create form over the stock credentials route — nothing + // provider-specific lives here. + id: "credentials", + title: SETTINGS_STRINGS.credentialsSectionTitle, icon: Key, gate: "credentials", - render: (ctx) => , + render: (ctx) => , }, { id: "people", diff --git a/apps/web/src/settings/strings.ts b/apps/web/src/settings/strings.ts index 4d5b041691..5e3ec37e13 100644 --- a/apps/web/src/settings/strings.ts +++ b/apps/web/src/settings/strings.ts @@ -143,65 +143,27 @@ export const SETTINGS_STRINGS = { credentialRevokedToast: "Key revoked", - connectionsSectionTitle: "AI providers", - connectionsSectionDescription: - "Choose the model route Myra and new agents use, then connect the providers behind it. Changing the default does not rewrite existing agents.", - connectionsLoadError: "connections", - connectionsStatusNotConnected: "Not connected", - connectionsStatusConnected: "Connected", - connectionsStatusNeedsAttention: "Needs attention", - connectionsStatusNotConfigured: "Needs setup", - connectionsNotConfiguredHint: - "An OAuth app needs to be registered for this connector before anyone can connect it.", - connectionsConnectAction: "Connect", - connectionsReconnectAction: "Reconnect", - connectionsDisconnectAction: "Disconnect", - connectionsDisconnectConfirm: "Disconnect for good?", - connectionsDisconnectError: "Couldn't disconnect — try again.", - connectionsDefaultModelLine: (model: string) => `Default model: ${model}`, - connectionsDefaultModelLabel: "Default model:", - connectionsSetDefaultModelError: "Couldn't set the default model — try again.", - connectionsPinnedByPrefix: "Used by: ", - connectionsPinnedByNone: "Not pinned by any workflow", - connectionsPinnedByApproximationNote: - "Based on declared pins, not live usage — may lag actual tool usage.", - connectionsDialogConnectTitle: (name: string) => `Connect ${name}`, - connectionsDialogReconnectTitle: (name: string) => `Reconnect ${name}`, - connectionsDialogDescription: - "Sealed on save — this key is never shown again after create. A bad key never gets saved; connecting surfaces the problem right here.", - connectionsDialogUrlDescription: - "The base address of the service to connect. A bad address never gets saved; connecting surfaces the problem right here.", - connectionsKeyLabel: "API key", - connectionsUrlLabel: "Base URL", - connectionsConnectDialogAction: "Connect", - connectionsConnecting: "Connecting…", - connectionsSaving: "Saving…", - connectionsCancel: "Cancel", - connectionsConnectedToast: (name: string) => `${name} connected`, - connectionsAdvancedSummary: "All credentials — including certificates and other types", - connectionsWebhookLoadError: "Couldn't load the Granola webhook", - connectionsWebhookNotSetUp: "Not set up", - connectionsWebhookSetUpAction: "Set up", - connectionsWebhookManageAction: "Manage", - connectionsWebhookTriggerCount: (count: number) => - `${count} webhook${count === 1 ? "" : "s"} wired`, - connectionsWebhookLastDelivery: (when: string) => `Last delivery ${when}`, - connectionsWebhookNoDeliveries: "No deliveries yet", - connectionsWebhookDirectionNote: "Workbench gives you a signed webhook address Granola calls.", - connectionsWebhookDialogTitle: "Granola inbound webhook", - connectionsWebhookDialogDescription: - "This address is yours to give to Granola — nothing to paste from Granola's side. The signing secret proves a delivery really came from Granola; it's shown once, right after you create or rotate it.", - connectionsWebhookHookUrlLabel: "Hook URL", - connectionsWebhookCopyHookUrl: "Copy hook URL", - connectionsWebhookSigningSecretLabel: "Signing secret", - connectionsWebhookCopySigningSecret: "Copy signing secret", - connectionsWebhookNoRoutine: - "No Granola call-notes routine yet. Create one from Routines, then come back here to wire its inbound webhook.", - connectionsWebhookCreateAction: "Create", - connectionsWebhookRotateAction: "Rotate secret", - connectionsWebhookRotating: "Rotating…", - connectionsWebhookHiddenSecretNote: - "Hidden — shown only once, right after creation or a rotate. Rotate to issue (and reveal) a new one; the old secret stops verifying immediately.", + credentialsSectionTitle: "Credentials", + credentialsSectionDescription: + "Keys this bench's agents and workflows can use, stored through the platform's own credential store.", + credentialsLoadError: "credentials", + credentialsEmptyTitle: "No credentials yet", + credentialsEmptyDescription: "Add a key below to give agents something to authenticate with.", + credentialsNameLabel: "Name", + credentialsTypeLabel: "Type", + credentialsSecretLabel: "Secret", + credentialsSecretPlaceholder: "Pasted once, never shown again", + credentialsStatusLabel: "Status", + credentialsCreateAction: "Add credential", + credentialsCreateDialogTitle: "Add a credential", + credentialsCreateDialogDescription: + "Sealed on save — this secret is never shown again after create.", + credentialsCreateSubmit: "Add", + credentialsCreateCancel: "Cancel", + credentialsCreateError: "Couldn't save that credential — try again.", + credentialsDeleteAction: "Delete", + credentialsDeleteConfirm: "Delete this credential for good?", + credentialsDeleteError: "Couldn't delete that credential — try again.", auditSectionTitle: "Audit", auditSectionDescription: "Who changed what, when — orientation only.", diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 03cbf84e4a..3fda1cc080 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -34,7 +34,6 @@ import { useCloseCanvas, useToggleCanvasFocus, } from "./canvas-availability"; -import { ProviderHealthBanner } from "./provider-health-banner"; import { Sidebar } from "./sidebar"; import { ShellContextMenu } from "./context-menu/shell-context-menu"; @@ -136,7 +135,6 @@ export function AppShell({
- {routeHasNoStageTopBar(path) ? ( d.id === provider); - return descriptor?.displayName ?? provider; -} - -// Fixed, pre-written copy per classified category — the one -// place a `ProviderHealthRecord.category` becomes a sentence. Never a -// provider's own error text: see `provider-health.ts`'s module header for -// why only a closed enum ever reaches this far. -const CATEGORY_COPY: Readonly> = { - credential_failure: "turned down your key.", - quota_exhausted: "says this key is out of credit.", -}; - -function bannerMessage(banner: ProviderHealthBannerState): string { - return `${providerDisplayName(banner.provider)} ${CATEGORY_COPY[banner.category]}`; -} - -type VisibleChrome = - | { readonly kind: "error" } - | { readonly kind: "unhealthy"; readonly banner: ProviderHealthBannerState }; - -function visibleFromChrome(chrome: ProviderHealthChrome): VisibleChrome | null { - if (chrome.kind === "error") return { kind: "error" }; - if (chrome.kind === "unhealthy") return chrome; - return null; -} - -export function ProviderHealthBanner({ path }: { readonly path: string }) { - const chrome = useProviderHealthChrome(); - const dismiss = useDismissProviderHealthBanner(); - const requestPluginsConnect = useRequestPluginsConnect(); - const navigate = useNavigate(); - - const visible = visibleFromChrome(chrome); - // Stable deps for the cache effect — `visible` is a fresh object each - // render, so depending on it would loop (setState → re-render → new - // object → effect again). - const visibleKind = visible?.kind ?? null; - const unhealthyBanner = chrome.kind === "unhealthy" ? chrome.banner : null; - - // Keeps the banner's last-known content mounted for a moment after - // `visible` goes null, so the collapse/fade-out transition below has - // something to animate away rather than the stage snapping shut - // instantly — see the module comment on hard-shoving the stage this - // replaces. The `role="alert"` below still comes off the LIVE chrome, - // not this cache, so a screen reader never sees a stale alert linger. - const [cachedVisible, setCachedVisible] = useState(visible); - useEffect(() => { - if (visibleKind === "error") { - setCachedVisible({ kind: "error" }); - return; - } - if (visibleKind === "unhealthy" && unhealthyBanner !== null) { - setCachedVisible({ kind: "unhealthy", banner: unhealthyBanner }); - return; - } - const timeout = setTimeout(() => setCachedVisible(null), COLLAPSE_TRANSITION_MS); - return () => clearTimeout(timeout); - }, [visibleKind, unhealthyBanner]); - - const isOpen = visible !== null; - - if (!isProviderHealthRecoverySurface(path)) { - return null; - } - - const handleFix = () => { - if (chrome.kind !== "unhealthy") return; - if (chrome.banner.zeroWorkingProviders) { - navigate(ONBOARDING_PATH); - return; - } - requestPluginsConnect(chrome.banner.provider); - navigate(PLUGINS_PATH); - }; - - // A ready all-clear still mounts a zero-size marker so tests (and any - // future chrome) can tell "actually healthy" from "unknown / not yet - // polled" without treating empty DOM as healthy. - if (chrome.kind === "healthy" && cachedVisible === null) { - return