From a479b8e66c6284328e2c17111b8d8fe851735478 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:27:23 -0700 Subject: [PATCH 1/6] Add tests for a self-served onboarding credential Covers proving an Anthropic key with a real call before it's stored, seeding a personal bench once it checks out, and the route-level error envelopes for an anonymous request, a missing key, and a rejected one. --- apps/web/test/onboarding.test.tsx | 46 +++++- .../hub-client/test/credential-test.test.ts | 79 +++++++++ .../test/complete-credential.test.ts | 156 ++++++++++++++++++ packages/onboarding/test/routes.test.ts | 43 +++++ 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 packages/hub-client/test/credential-test.test.ts create mode 100644 packages/onboarding/test/complete-credential.test.ts diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 6a452c45b..9aad436cd 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -8,7 +8,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { App } from "../src/app"; -import { triggerFirstLoginProvisioning } from "../src/onboarding"; +import { + submitCredential, + triggerFirstLoginProvisioning, +} from "../src/onboarding"; import type { SessionState } from "../src/session"; const realFetch = globalThis.fetch; @@ -70,6 +73,47 @@ describe("triggerFirstLoginProvisioning", () => { }); }); +describe("submitCredential", () => { + test("a rejected key comes back as a rejected outcome with the hub's own reason", async () => { + globalThis.fetch = (async () => + json( + { error: { code: "invalid_credential", message: "invalid x-api-key" } }, + 422, + )) as unknown as typeof fetch; + + const result = await submitCredential("sk-ant-bad"); + expect(result).toEqual({ kind: "rejected", message: "invalid x-api-key" }); + }); + + test("a seeded bench reports which routines were confirmed", async () => { + globalThis.fetch = (async () => + json({ + kind: "seeded", + tenantId: "ten_1", + tenantSlug: "ada-user1", + workflows: ["echo", "assistant"], + })) as unknown as typeof fetch; + + const result = await submitCredential("sk-ant-good"); + expect(result).toEqual({ + kind: "seeded", + tenantSlug: "ada-user1", + workflows: ["echo", "assistant"], + }); + }); + + test("a network failure is reported, never mistaken for a bad key", async () => { + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + + const result = await submitCredential("sk-ant-good"); + expect(result.kind).toBe("error"); + if (result.kind !== "error") throw new Error("unreachable"); + expect(result.message).toContain("connection refused"); + }); +}); + describe("App with a provisioning error", () => { test("blocks the shell with a retry action instead of rendering it", () => { const markup = renderToStaticMarkup( diff --git a/packages/hub-client/test/credential-test.test.ts b/packages/hub-client/test/credential-test.test.ts new file mode 100644 index 000000000..cb2528be6 --- /dev/null +++ b/packages/hub-client/test/credential-test.test.ts @@ -0,0 +1,79 @@ +// Real inference-shaped calls, fake network: these tests exercise +// `testAnthropicCredential` against a stub `fetch` that plays the two +// outcomes an onboarding user actually hits — a key Anthropic accepts, +// and one it rejects with 401 — plus a transport failure. The request +// itself is built by `@intx/inference`'s real Anthropic adapter, so +// what's under test is workbench's wiring of that adapter to a fetch +// call, not the wire format (that's Interchange's to test). +import { describe, expect, test } from "bun:test"; +import { + testAnthropicCredential, + type FetchLike, +} from "../src/credential-test"; + +describe("testAnthropicCredential", () => { + test("reports ok when the key is accepted", async () => { + const fetchImpl: FetchLike = async () => + new Response(JSON.stringify({ type: "message", content: [] }), { + status: 200, + }); + + const result = await testAnthropicCredential({ + apiKey: "sk-ant-real-key", + fetchImpl, + }); + + expect(result).toEqual({ ok: true }); + }); + + test("reports the specific reason when Anthropic rejects the key", async () => { + const fetchImpl: FetchLike = async () => + new Response( + JSON.stringify({ + type: "error", + error: { type: "authentication_error", message: "invalid x-api-key" }, + }), + { status: 401 }, + ); + + const result = await testAnthropicCredential({ + apiKey: "sk-ant-wrong", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("invalid x-api-key"); + } + }); + + test("reports a transport failure without pretending the key is bad", async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND api.anthropic.com"); + }; + + const result = await testAnthropicCredential({ + apiKey: "sk-ant-real-key", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.message).toContain("ENOTFOUND"); + } + }); + + test("sends the real API key, never the adapter's sentinel", async () => { + let seenHeaders: Record = {}; + const fetchImpl: FetchLike = async (_url, init) => { + seenHeaders = Object.fromEntries(new Headers(init?.headers).entries()); + return new Response(JSON.stringify({ type: "message" }), { + status: 200, + }); + }; + + await testAnthropicCredential({ apiKey: "sk-ant-secret", fetchImpl }); + + expect(seenHeaders["x-api-key"]).toBe("sk-ant-secret"); + }); +}); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts new file mode 100644 index 000000000..99becff42 --- /dev/null +++ b/packages/onboarding/test/complete-credential.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, test } from "bun:test"; +import type { ApiCall, WorkflowPusher } from "@workbench/hub-client"; +import { completeCredentialSetup } from "../src/complete-credential"; + +const TENANT_ID = "ten_personal"; +const PRINCIPAL_ID = "prn_personal"; +const TENANT_SLUG = "alice-user1"; + +const noopPush: WorkflowPusher = async () => "pushed"; + +function collector() { + const lines: string[] = []; + return { lines, log: (line: string) => lines.push(line) }; +} + +function principalsResponse() { + return { + status: 200, + data: { + data: [ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantName: "Alice's workbench", + tenantSlug: TENANT_SLUG, + kind: "user", + status: "active", + roles: [], + }, + ], + nextCursor: null, + }, + cookies: [], + }; +} + +function tenantResponse() { + return { + status: 200, + data: { + id: TENANT_ID, + name: "Alice's workbench", + slug: TENANT_SLUG, + domain: "alice-user1.bench.local", + parentId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; +} + +describe("completeCredentialSetup", () => { + test("an invalid key never touches the tenant", async () => { + let apiCalls = 0; + const api: ApiCall = async () => { + apiCalls += 1; + throw new Error("unexpected call with an invalid credential"); + }; + + const result = await completeCredentialSetup({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + apiKey: "sk-ant-bad", + pushWorkflow: noopPush, + log: collector().log, + testCredential: async () => ({ + ok: false, + message: "invalid x-api-key", + }), + seedCatalogFn: async () => { + throw new Error("seedCatalog must not run for an invalid credential"); + }, + seedTenantFn: async () => { + throw new Error("seedTenant must not run for an invalid credential"); + }, + }); + + expect(result).toEqual({ + kind: "invalid-credential", + message: "invalid x-api-key", + }); + expect(apiCalls).toBe(0); + }); + + test("a valid key with no personal bench yet is reported, not guessed at", async () => { + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await completeCredentialSetup({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + apiKey: "sk-ant-good", + pushWorkflow: noopPush, + log: collector().log, + testCredential: async () => ({ ok: true }), + }); + + expect(result).toEqual({ kind: "no-personal-bench" }); + }); + + test("a valid key seeds the caller's own personal bench and reports what ran", async () => { + const seedCatalogCalls: unknown[] = []; + const seedTenantCalls: unknown[] = []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse(); + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return tenantResponse(); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await completeCredentialSetup({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + apiKey: "sk-ant-good", + pushWorkflow: noopPush, + log: collector().log, + testCredential: async () => ({ ok: true }), + seedCatalogFn: async (args) => { + seedCatalogCalls.push(args); + }, + seedTenantFn: async (args) => { + seedTenantCalls.push(args); + }, + }); + + expect(result).toEqual({ + kind: "seeded", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + workflows: ["echo", "assistant"], + }); + expect(seedCatalogCalls).toHaveLength(1); + expect(seedTenantCalls).toHaveLength(1); + }); +}); diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index bc32f22fb..152686bd0 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -61,3 +61,46 @@ describe("POST /provision", () => { expect(body.error.code).toBe("unauthorized"); }); }); + +describe("POST /credential", () => { + test("an anonymous request is rejected before any credential is tested", async () => { + const routes = createOnboardingRoutes({ + hubUrl: "http://127.0.0.1:0", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + + const response = await routes.request("/credential", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey: "sk-ant-whatever" }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("unauthorized"); + }); + + test("a missing key is rejected with a specific message, no network call made", async () => { + const routes = createOnboardingRoutes({ + hubUrl: "http://127.0.0.1:0", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + const app = mountAuthenticated(routes); + + const response = await app.request("/credential", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("invalid_request"); + }); +}); From 7774bcd5626cefef11f402f09990ce3559bef079 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:27:35 -0700 Subject: [PATCH 2/6] Onboard a new user straight to a fired routine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First-run no longer stops at "no credential configured": a signed-up user with no seed model lands on a guided step that asks for their own Anthropic key, proves it with a real call through Interchange's own Anthropic adapter before anything is stored, and — once it checks out — seeds their personal bench and confirms the default routines actually answer, all without leaving the page. A bad key is rejected with Anthropic's own reason, never a bare failure; a good key's owner lands on a screen showing exactly which routines are now live, with one click into their starter channel. --- apps/web/src/onboarding.ts | 63 +++++ apps/web/src/pages/onboarding-page.tsx | 263 ++++++++++++++++-- bun.lock | 1 + packages/hub-client/package.json | 1 + packages/hub-client/src/credential-test.ts | 102 +++++++ packages/hub-client/src/index.ts | 5 + .../onboarding/src/complete-credential.ts | 142 ++++++++++ packages/onboarding/src/index.ts | 5 + packages/onboarding/src/routes.ts | 75 +++++ 9 files changed, 631 insertions(+), 26 deletions(-) create mode 100644 packages/hub-client/src/credential-test.ts create mode 100644 packages/onboarding/src/complete-credential.ts diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index aa15f4c63..ac359717a 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -82,3 +82,66 @@ export async function triggerFirstLoginProvisioning(): Promise }; } } + +const CredentialSeeded = type({ + kind: "'seeded'", + tenantSlug: "string", + workflows: "string[]", +}); + +export type CredentialOutcome = + | { + readonly kind: "seeded"; + readonly tenantSlug: string; + readonly workflows: string[]; + } + | { readonly kind: "rejected"; readonly message: string } + | { readonly kind: "error"; readonly message: string }; + +/** + * Hands a user's own Anthropic key to the hub, which proves it with a + * real call before doing anything else with it, then seeds the + * caller's personal bench and confirms every default routine answers. + * A rejected key is reported by name (`"rejected"`) rather than folded + * into the same `"error"` bucket a broken hub call gets — the retry + * story is different for each. + */ +export async function submitCredential( + apiKey: string, +): Promise { + try { + const response = await fetch("/api/onboarding/credential", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ apiKey }), + }); + const body: unknown = await response.json().catch(() => null); + if (!response.ok) { + const envelope = ErrorEnvelope(body); + const message = + envelope instanceof type.errors + ? `The hub answered ${response.status} while checking your key.` + : envelope.error.message; + return response.status === 422 + ? { kind: "rejected", message } + : { kind: "error", message }; + } + const parsed = CredentialSeeded(body); + if (parsed instanceof type.errors) { + return { + kind: "error", + message: `Unexpected credential response shape: ${parsed.summary}`, + }; + } + return { + kind: "seeded", + tenantSlug: parsed.tenantSlug, + workflows: parsed.workflows, + }; + } catch (cause) { + return { + kind: "error", + message: cause instanceof Error ? cause.message : String(cause), + }; + } +} diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 512ebbdb7..2510dfd4b 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -1,11 +1,11 @@ -// Landing point for a session the first-login hook just provisioned a -// personal bench for. Renaming the bench and choosing a starter channel -// are both server-side decisions `@workbench/onboarding` already made -// during provisioning (see that package's provision.ts) — this screen's -// job is just the guidance half of first-run: a fast orientation to the -// three things new to every arrival (channels, routines, the library) -// and how to bring an agent into a conversation, before landing in the -// channel that provisioning already created. +// First-run, end to end: land here fresh with no usable inference +// credential, add one for real, and watch the default routines fire. +// The heavy lifting — proving the key with a real call, seeding the +// bench, deploying and confirming every default workflow — all happens +// server-side in `@workbench/onboarding`; this page is the guided +// wizard around it. A session that already has a seeded bench (an +// operator-configured seed key, or a returning member) skips straight +// to the orientation cards this screen always ended with. import { Button, @@ -13,12 +13,27 @@ import { CardDescription, CardHeader, CardTitle, + EmptyState, + HorizontalStepper, + Input, PageShell, + ProgressChecklist, Section, } from "@corbits/react-ui"; -import { AtSign, Bot, Library, MessageSquare } from "lucide-react"; +import type { ChecklistStep, WorkflowStep } from "@corbits/react-ui"; +import { + AtSign, + Bot, + CircleAlert, + KeyRound, + Library, + MessageSquare, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import type { FormEvent } from "react"; -import { Link } from "../navigation"; +import { Link, useNavigate } from "../navigation"; +import { submitCredential, triggerFirstLoginProvisioning } from "../onboarding"; const GUIDANCE_CARDS = [ { @@ -47,27 +62,223 @@ const GUIDANCE_CARDS = [ }, ] as const; +const ROUTINE_LABELS: Readonly> = { + echo: "Echo routine", + assistant: "Assistant routine", +}; + +function routineLabel(assetName: string): string { + return ROUTINE_LABELS[assetName] ?? assetName; +} + +type WizardState = + | { readonly phase: "loading" } + | { readonly phase: "provisioning-error"; readonly message: string } + | { readonly phase: "credential"; readonly error: string | null } + | { readonly phase: "submitting" } + | { + readonly phase: "seeded"; + readonly tenantSlug: string; + readonly workflows: readonly string[]; + } + | { readonly phase: "guidance" }; + +function GuidanceCards() { + return ( +
+ {GUIDANCE_CARDS.map((card) => ( + + + {card.icon} + {card.title} + {card.description} + + + ))} +
+ ); +} + +function wizardSteps(phase: WizardState["phase"]): WorkflowStep[] { + const credentialDone = phase === "seeded" || phase === "guidance"; + const credentialCurrent = phase === "credential" || phase === "submitting"; + return [ + { + number: 1, + label: "Add a credential", + status: credentialDone + ? "completed" + : credentialCurrent + ? "current" + : "pending", + }, + { + number: 2, + label: "Run your first routine", + status: + phase === "seeded" + ? "completed" + : credentialCurrent + ? "pending" + : "pending", + }, + ]; +} + export function OnboardingPage() { + const navigate = useNavigate(); + const [state, setState] = useState({ phase: "loading" }); + const [apiKey, setApiKey] = useState(""); + + const runProvisioning = useCallback(() => { + setState({ phase: "loading" }); + void triggerFirstLoginProvisioning().then((result) => { + if (result.kind === "error") { + setState({ phase: "provisioning-error", message: result.message }); + } else if (result.kind === "existing-member") { + setState({ phase: "guidance" }); + } else if (result.seeded) { + setState({ phase: "guidance" }); + } else { + setState({ phase: "credential", error: null }); + } + }); + }, []); + useEffect(runProvisioning, [runProvisioning]); + + const handleSubmitCredential = useCallback( + (event: FormEvent) => { + event.preventDefault(); + setState({ phase: "submitting" }); + void submitCredential(apiKey).then((outcome) => { + if (outcome.kind === "seeded") { + setState({ + phase: "seeded", + tenantSlug: outcome.tenantSlug, + workflows: outcome.workflows, + }); + } else { + setState({ phase: "credential", error: outcome.message }); + } + }); + }, + [apiKey], + ); + + if (state.phase === "loading") { + return ( + +
+
+
+
+ ); + } + + if (state.phase === "provisioning-error") { + return ( + + } + title="Couldn't set up your workbench" + description={state.message} + action={ + + } + /> + + ); + } + + if (state.phase === "guidance") { + return ( + +
+ + +
+
+ ); + } + + if (state.phase === "seeded") { + const checklist: ChecklistStep[] = state.workflows.map((assetName) => ({ + id: assetName, + label: routineLabel(assetName), + status: "done", + detail: "confirmed running with your credential", + })); + return ( + +
+ + + +
+
+ ); + } + + const submitting = state.phase === "submitting"; + const error = state.phase === "credential" ? state.error : null; + return (
-
- {GUIDANCE_CARDS.map((card) => ( - - - {card.icon} - {card.title} - {card.description} - - - ))} -
- + +
+ + setApiKey(event.target.value)} + required + disabled={submitting} + aria-describedby="onboarding-api-key-help" + /> +

+ + Get a key from the Anthropic console + {" "} + — it starts with sk-ant-. +

+ {error !== null && ( + } + title="That key didn't work" + description={error} + /> + )} + +
); diff --git a/bun.lock b/bun.lock index d08b1c09c..264021ffb 100644 --- a/bun.lock +++ b/bun.lock @@ -261,6 +261,7 @@ "dependencies": { "@corbits/assistant-workflow": "workspace:*", "@corbits/echo-workflow": "workspace:*", + "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", }, diff --git a/packages/hub-client/package.json b/packages/hub-client/package.json index be166c5cf..e73bcb364 100644 --- a/packages/hub-client/package.json +++ b/packages/hub-client/package.json @@ -13,6 +13,7 @@ "test": "bun test" }, "dependencies": { + "@intx/inference": "workspace:*", "@intx/types": "workspace:*", "arktype": "catalog:", "@corbits/assistant-workflow": "workspace:*", diff --git a/packages/hub-client/src/credential-test.ts b/packages/hub-client/src/credential-test.ts new file mode 100644 index 000000000..5df2dd820 --- /dev/null +++ b/packages/hub-client/src/credential-test.ts @@ -0,0 +1,102 @@ +// A real test call for a credential a person is about to hand the +// onboarding flow, made before it's ever stored: the request is built +// by `@intx/inference`'s own Anthropic adapter — the same code path a +// deployed workflow's inference call goes through — so the wire format +// under test is Interchange's, never workbench's guess at it. Only the +// transport (one `fetch`, no streaming consumed) and the pass/fail +// verdict belong to workbench. + +import { createAnthropicAdapter } from "@intx/inference/providers"; +import { CREDENTIAL_SENTINEL } from "@intx/inference"; +import { catalogModel, catalogProvider } from "./catalog-seed-data"; + +export type CredentialTestResult = + { readonly ok: true } | { readonly ok: false; readonly message: string }; + +export type FetchLike = ( + url: string, + init: { method: "POST"; headers: Headers; body: string }, +) => Promise; + +export type TestAnthropicCredentialArgs = { + readonly apiKey: string; + readonly fetchImpl?: FetchLike; +}; + +function anthropicErrorMessage(status: number, body: string): string { + const parsed: unknown = JSON.parse(body); + if ( + typeof parsed === "object" && + parsed !== null && + "error" in parsed && + typeof (parsed as { error: unknown }).error === "object" && + (parsed as { error: unknown }).error !== null && + "message" in (parsed as { error: { message: unknown } }).error && + typeof (parsed as { error: { message: unknown } }).error.message === + "string" + ) { + return (parsed as { error: { message: string } }).error.message; + } + return `Anthropic rejected the request with status ${status}`; +} + +/** + * Fires the smallest real Anthropic call a credential can be validated + * with — a one-token completion against the same model the onboarding + * catalog seeds — and reports whether the key works. Never stores or + * logs the key; the caller owns that decision once this says `ok`. + */ +export async function testAnthropicCredential( + args: TestAnthropicCredentialArgs, +): Promise { + const doFetch = args.fetchImpl ?? fetch; + const adapter = createAnthropicAdapter({ + sourceId: "onboarding-credential-test", + provider: catalogProvider.name, + model: catalogModel.canonicalName, + }); + + const request = adapter.buildRequest( + [ + { + role: "user", + content: [{ type: "text", text: "Reply with the word ok." }], + timestamp: Date.now(), + }, + ], + catalogModel.canonicalName, + { maxTokens: 1 }, + ); + + if (request.headers["x-api-key"] !== CREDENTIAL_SENTINEL) { + return { + ok: false, + message: + "internal error: the Anthropic adapter no longer uses the credential sentinel this test relies on", + }; + } + const headers = new Headers(request.headers); + headers.set("x-api-key", args.apiKey); + + let response: Response; + try { + response = await doFetch(`${catalogProvider.baseURL}${request.url}`, { + method: "POST", + headers, + body: request.body, + }); + } catch (cause) { + return { + ok: false, + message: + cause instanceof Error + ? `Could not reach Anthropic: ${cause.message}` + : `Could not reach Anthropic: ${String(cause)}`, + }; + } + + const body = await response.text(); + + if (response.ok) return { ok: true }; + return { ok: false, message: anthropicErrorMessage(response.status, body) }; +} diff --git a/packages/hub-client/src/index.ts b/packages/hub-client/src/index.ts index d8b5be1c7..313402641 100644 --- a/packages/hub-client/src/index.ts +++ b/packages/hub-client/src/index.ts @@ -27,3 +27,8 @@ export { catalogProvider, } from "./catalog-seed-data"; export { createGitWorkflowPusher } from "./workflow-push"; +export { testAnthropicCredential } from "./credential-test"; +export type { + CredentialTestResult, + TestAnthropicCredentialArgs, +} from "./credential-test"; diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts new file mode 100644 index 000000000..86be17945 --- /dev/null +++ b/packages/onboarding/src/complete-credential.ts @@ -0,0 +1,142 @@ +// The guided credential step of first-run: a signed-in user who reached +// onboarding with no seed model configured pastes their own Anthropic +// key here. The key is proven with a real call before anything is +// stored (see `@workbench/hub-client`'s `testAnthropicCredential`, +// which itself goes through `@intx/inference`'s own Anthropic adapter), +// and only once it's proven does this seed the caller's own personal +// bench — the same `seedCatalog` + `seedTenant` the first-login hook +// runs when a hub-owned key is configured, so a self-served key and an +// operator-configured one land the same bench. + +import { PrincipalSummary, TenantResponse, paginatedSchema } from "@intx/types"; +import { + DEFAULT_WORKFLOWS, + parseAs, + seedCatalog, + seedTenant, + testAnthropicCredential, + catalogProvider, + catalogModel, + type ApiCall, + type SeedCatalogArgs, + type SeedTenantArgs, + type TestAnthropicCredentialArgs, + type WorkflowPusher, +} from "@workbench/hub-client"; +import { personalTenantSlug } from "./provision"; + +export type CompleteCredentialResult = + | { readonly kind: "invalid-credential"; readonly message: string } + | { readonly kind: "no-personal-bench" } + | { + readonly kind: "seeded"; + readonly tenantId: string; + readonly tenantSlug: string; + readonly workflows: string[]; + }; + +export type CompleteCredentialArgs = { + api: ApiCall; + cookies: string[]; + hubUrl: string; + userId: string; + userEmail: string; + apiKey: string; + pushWorkflow: WorkflowPusher; + log: (line: string) => void; + testCredential?: ( + args: TestAnthropicCredentialArgs, + ) => ReturnType; + seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; + seedTenantFn?: (args: SeedTenantArgs) => ReturnType; +}; + +async function findPersonalTenant( + api: ApiCall, + cookies: string[], + expectedSlug: string, +): Promise< + { tenantId: string; tenantSlug: string; principalId: string } | undefined +> { + const response = await api("GET", "/api/me/principals", undefined, cookies); + const summary = parseAs( + paginatedSchema(PrincipalSummary), + response.data, + "principals response", + ); + return summary.data + .map((p) => ({ + tenantId: p.tenantId, + tenantSlug: p.tenantSlug, + principalId: p.principalId, + })) + .find((p) => p.tenantSlug === expectedSlug); +} + +/** + * Proves an onboarding user's Anthropic key with a real call, then + * seeds their own personal bench with it. A bad key never reaches the + * tenant at all — the credential test runs first and short-circuits + * everything else. + */ +export async function completeCredentialSetup( + args: CompleteCredentialArgs, +): Promise { + const testCredential = args.testCredential ?? testAnthropicCredential; + const runSeedCatalog = args.seedCatalogFn ?? seedCatalog; + const runSeedTenant = args.seedTenantFn ?? seedTenant; + + const test = await testCredential({ apiKey: args.apiKey }); + if (!test.ok) return { kind: "invalid-credential", message: test.message }; + + const expectedSlug = personalTenantSlug(args.userEmail, args.userId); + const own = await findPersonalTenant(args.api, args.cookies, expectedSlug); + if (!own) return { kind: "no-personal-bench" }; + + const tenantResponse = await args.api( + "GET", + `/api/tenants/${own.tenantId}`, + undefined, + args.cookies, + ); + const tenant = parseAs( + TenantResponse, + tenantResponse.data, + "tenant response", + ); + + await runSeedCatalog({ + api: args.api, + cookies: args.cookies, + tenantId: own.tenantId, + apiKey: args.apiKey, + log: args.log, + }); + + await runSeedTenant({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenant: { + tenantId: own.tenantId, + principalId: own.principalId, + domain: tenant.domain, + }, + model: { + provider: catalogProvider.name, + model: catalogModel.canonicalName, + baseURL: catalogProvider.baseURL, + apiKey: args.apiKey, + }, + pushWorkflow: args.pushWorkflow, + log: args.log, + workflows: DEFAULT_WORKFLOWS, + }); + + return { + kind: "seeded", + tenantId: own.tenantId, + tenantSlug: own.tenantSlug, + workflows: DEFAULT_WORKFLOWS.map((workflow) => workflow.assetName), + }; +} diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts index 626d5f063..6baa0e8e9 100644 --- a/packages/onboarding/src/index.ts +++ b/packages/onboarding/src/index.ts @@ -3,5 +3,10 @@ export { provisionPersonalTenantIfNeeded, } from "./provision"; export type { ProvisionArgs, ProvisionResult } from "./provision"; +export { completeCredentialSetup } from "./complete-credential"; +export type { + CompleteCredentialArgs, + CompleteCredentialResult, +} from "./complete-credential"; export { createOnboardingRoutes } from "./routes"; export type { CreateOnboardingRoutesDeps } from "./routes"; diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index e197165f1..fd46d0659 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -11,7 +11,11 @@ import { type WorkflowPusher, } from "@workbench/hub-client"; import { Hono } from "hono"; +import { type } from "arktype"; import { provisionPersonalTenantIfNeeded } from "./provision"; +import { completeCredentialSetup } from "./complete-credential"; + +const SubmitCredential = type({ apiKey: "string > 0" }); export type CreateOnboardingRoutesDeps = { hubUrl: string; @@ -83,5 +87,76 @@ export function createOnboardingRoutes( } }); + app.post("/credential", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json( + { error: { code: "unauthorized", message: "Authentication required" } }, + 401, + ); + } + + const body: unknown = await c.req.json().catch(() => null); + const parsed = SubmitCredential(body); + if (parsed instanceof type.errors) { + return c.json( + { + error: { + code: "invalid_request", + message: `An Anthropic API key is required: ${parsed.summary}`, + }, + }, + 400, + ); + } + + const cookies = cookiesFromHeader(c.req.header("cookie")); + try { + const result = await completeCredentialSetup({ + api, + cookies, + hubUrl: deps.hubUrl, + userId: user.id, + userEmail: user.email, + apiKey: parsed.apiKey, + pushWorkflow: deps.pushWorkflow, + log: deps.log, + }); + + if (result.kind === "invalid-credential") { + return c.json( + { error: { code: "invalid_credential", message: result.message } }, + 422, + ); + } + if (result.kind === "no-personal-bench") { + return c.json( + { + error: { + code: "no_personal_bench", + message: + "No personal bench was found for this account yet. Reload and try again.", + }, + }, + 409, + ); + } + return c.json(result, 200); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + deps.log(`credential setup failed for user ${user.id}: ${message}`); + return c.json( + { + error: { + code: "credential_setup_failed", + message: + "The key checked out, but setting up your bench failed. Try again in a moment.", + }, + }, + 500, + ); + } + }); + return app; } From aaeac114583bfcef7f1e9e849c501ec928bf614b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:27:42 -0700 Subject: [PATCH 3/6] Update docs: first-run works without ANTHROPIC_API_KEY set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explains that a signed-up user with no hub-owned seed key isn't left without routines — onboarding walks them through adding and proving their own key on the spot. --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 4249320d7..b58da1668 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,13 @@ their configuration from `.env` (see `.env.example`) and are safe to re-run. is actually launchable; without it, everything above still runs, but inference errors until you set it and re-run `bun run seed`. +Leaving `ANTHROPIC_API_KEY` unset doesn't just apply to the administrator +account: anyone who signs up gets a personal bench with no default routines +deployed, and first-run tells them exactly that. Onboarding walks them +through pasting their own Anthropic key, proves it with a real call before +storing anything, then deploys and confirms the default routines on the +spot — no separate `bun run seed` step, no docs to read. + ### OAuth sign-in Email/password sign-in always works. To let people sign in with an From 4e4f9d5da573e14a4d1e6bdcbd6e140f4dd59335 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:40:23 -0700 Subject: [PATCH 4/6] Add tests for multi-provider credentials on the native storage route Covers testing a key against Anthropic, OpenAI, or Google through each provider's own adapter; a test-only endpoint that never stores anything; and seeding that skips the Anthropic-only catalog for other providers while still deploying and confirming routines with them. --- apps/web/test/onboarding.test.tsx | 45 +++++- .../hub-client/test/credential-test.test.ts | 136 ++++++++++-------- .../test/complete-credential.test.ts | 55 ++++++- packages/onboarding/test/routes.test.ts | 80 ++++++++++- 4 files changed, 245 insertions(+), 71 deletions(-) diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 9aad436cd..de2bcf984 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -10,6 +10,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { App } from "../src/app"; import { submitCredential, + testCredential, triggerFirstLoginProvisioning, } from "../src/onboarding"; import type { SessionState } from "../src/session"; @@ -73,6 +74,31 @@ describe("triggerFirstLoginProvisioning", () => { }); }); +describe("testCredential", () => { + test("a rejected key is reported with the hub's own reason", async () => { + let requestBody: unknown = null; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + requestBody = JSON.parse((init as RequestInit).body as string); + return json( + { error: { code: "invalid_credential", message: "invalid api key" } }, + 422, + ); + }) as unknown as typeof fetch; + + const result = await testCredential("openai", "sk-bad"); + expect(result).toEqual({ ok: false, message: "invalid api key" }); + expect(requestBody).toEqual({ provider: "openai", apiKey: "sk-bad" }); + }); + + test("an accepted key reports ok", async () => { + globalThis.fetch = (async () => + json({ ok: true })) as unknown as typeof fetch; + + const result = await testCredential("anthropic", "sk-ant-good"); + expect(result).toEqual({ ok: true }); + }); +}); + describe("submitCredential", () => { test("a rejected key comes back as a rejected outcome with the hub's own reason", async () => { globalThis.fetch = (async () => @@ -81,25 +107,32 @@ describe("submitCredential", () => { 422, )) as unknown as typeof fetch; - const result = await submitCredential("sk-ant-bad"); + const result = await submitCredential("anthropic", "sk-ant-bad"); expect(result).toEqual({ kind: "rejected", message: "invalid x-api-key" }); }); test("a seeded bench reports which routines were confirmed", async () => { - globalThis.fetch = (async () => - json({ + let requestBody: unknown = null; + globalThis.fetch = (async (_url: string, init: RequestInit) => { + requestBody = JSON.parse((init as RequestInit).body as string); + return json({ kind: "seeded", tenantId: "ten_1", tenantSlug: "ada-user1", workflows: ["echo", "assistant"], - })) as unknown as typeof fetch; + }); + }) as unknown as typeof fetch; - const result = await submitCredential("sk-ant-good"); + const result = await submitCredential("google-genai", "AIza-good"); expect(result).toEqual({ kind: "seeded", tenantSlug: "ada-user1", workflows: ["echo", "assistant"], }); + expect(requestBody).toEqual({ + provider: "google-genai", + apiKey: "AIza-good", + }); }); test("a network failure is reported, never mistaken for a bad key", async () => { @@ -107,7 +140,7 @@ describe("submitCredential", () => { throw new Error("connection refused"); }) as unknown as typeof fetch; - const result = await submitCredential("sk-ant-good"); + const result = await submitCredential("anthropic", "sk-ant-good"); expect(result.kind).toBe("error"); if (result.kind !== "error") throw new Error("unreachable"); expect(result.message).toContain("connection refused"); diff --git a/packages/hub-client/test/credential-test.test.ts b/packages/hub-client/test/credential-test.test.ts index cb2528be6..244e5cc42 100644 --- a/packages/hub-client/test/credential-test.test.ts +++ b/packages/hub-client/test/credential-test.test.ts @@ -1,79 +1,101 @@ // Real inference-shaped calls, fake network: these tests exercise -// `testAnthropicCredential` against a stub `fetch` that plays the two -// outcomes an onboarding user actually hits — a key Anthropic accepts, -// and one it rejects with 401 — plus a transport failure. The request -// itself is built by `@intx/inference`'s real Anthropic adapter, so -// what's under test is workbench's wiring of that adapter to a fetch -// call, not the wire format (that's Interchange's to test). +// `testProviderCredential` against a stub `fetch` that plays the two +// outcomes an onboarding user actually hits — a key the provider +// accepts, and one it rejects with 401 — plus a transport failure, for +// each of the three supported providers. The request itself is built +// by `@intx/inference`'s real adapter for that provider, so what's +// under test is workbench's wiring of that adapter to a fetch call, +// not the wire format (that's Interchange's to test). import { describe, expect, test } from "bun:test"; import { - testAnthropicCredential, + supportedCredentialProviders, + testProviderCredential, type FetchLike, + type SupportedCredentialProvider, } from "../src/credential-test"; -describe("testAnthropicCredential", () => { - test("reports ok when the key is accepted", async () => { - const fetchImpl: FetchLike = async () => - new Response(JSON.stringify({ type: "message", content: [] }), { - status: 200, +describe("supportedCredentialProviders", () => { + test("lists Anthropic, OpenAI, and Google", () => { + expect( + supportedCredentialProviders() + .map((p) => p.id) + .sort(), + ).toEqual(["anthropic", "google-genai", "openai"]); + }); +}); + +describe("testProviderCredential", () => { + const providers: SupportedCredentialProvider[] = [ + "anthropic", + "openai", + "google-genai", + ]; + + for (const provider of providers) { + test(`${provider}: reports ok when the key is accepted`, async () => { + const fetchImpl: FetchLike = async () => + new Response(JSON.stringify({}), { status: 200 }); + + const result = await testProviderCredential({ + provider, + apiKey: "test-real-key", + fetchImpl, }); - const result = await testAnthropicCredential({ - apiKey: "sk-ant-real-key", - fetchImpl, + expect(result).toEqual({ ok: true }); }); - expect(result).toEqual({ ok: true }); - }); + test(`${provider}: reports the specific reason when the key is rejected`, async () => { + const fetchImpl: FetchLike = async () => + new Response( + JSON.stringify({ error: { message: "invalid api key" } }), + { status: 401 }, + ); - test("reports the specific reason when Anthropic rejects the key", async () => { - const fetchImpl: FetchLike = async () => - new Response( - JSON.stringify({ - type: "error", - error: { type: "authentication_error", message: "invalid x-api-key" }, - }), - { status: 401 }, - ); + const result = await testProviderCredential({ + provider, + apiKey: "test-wrong-key", + fetchImpl, + }); - const result = await testAnthropicCredential({ - apiKey: "sk-ant-wrong", - fetchImpl, + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("invalid api key"); }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.message).toContain("invalid x-api-key"); - } - }); + test(`${provider}: reports a transport failure without pretending the key is bad`, async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; - test("reports a transport failure without pretending the key is bad", async () => { - const fetchImpl: FetchLike = async () => { - throw new Error("getaddrinfo ENOTFOUND api.anthropic.com"); - }; + const result = await testProviderCredential({ + provider, + apiKey: "test-real-key", + fetchImpl, + }); - const result = await testAnthropicCredential({ - apiKey: "sk-ant-real-key", - fetchImpl, + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("ENOTFOUND"); }); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.message).toContain("ENOTFOUND"); - } - }); + test(`${provider}: sends the real API key, never the adapter's sentinel`, async () => { + let seenHeaders: Record = {}; + const fetchImpl: FetchLike = async (_url, init) => { + seenHeaders = Object.fromEntries(new Headers(init.headers).entries()); + return new Response(JSON.stringify({}), { status: 200 }); + }; - test("sends the real API key, never the adapter's sentinel", async () => { - let seenHeaders: Record = {}; - const fetchImpl: FetchLike = async (_url, init) => { - seenHeaders = Object.fromEntries(new Headers(init?.headers).entries()); - return new Response(JSON.stringify({ type: "message" }), { - status: 200, + await testProviderCredential({ + provider, + apiKey: "test-secret-key", + fetchImpl, }); - }; - await testAnthropicCredential({ apiKey: "sk-ant-secret", fetchImpl }); - - expect(seenHeaders["x-api-key"]).toBe("sk-ant-secret"); - }); + const sentValues = Object.values(seenHeaders); + expect( + sentValues.some((value) => value.includes("test-secret-key")), + ).toBe(true); + expect(sentValues).not.toContain(""); + expect(sentValues).not.toContain(""); + }); + } }); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 99becff42..b59fa935c 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -64,6 +64,7 @@ describe("completeCredentialSetup", () => { hubUrl: "http://localhost:3000", userId: "user_1", userEmail: "alice@example.com", + provider: "anthropic", apiKey: "sk-ant-bad", pushWorkflow: noopPush, log: collector().log, @@ -104,6 +105,7 @@ describe("completeCredentialSetup", () => { hubUrl: "http://localhost:3000", userId: "user_1", userEmail: "alice@example.com", + provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, log: collector().log, @@ -113,9 +115,10 @@ describe("completeCredentialSetup", () => { expect(result).toEqual({ kind: "no-personal-bench" }); }); - test("a valid key seeds the caller's own personal bench and reports what ran", async () => { + test("a valid Anthropic key seeds the catalog, the tenant, and reports what ran", async () => { const seedCatalogCalls: unknown[] = []; - const seedTenantCalls: unknown[] = []; + const seedTenantCalls: { model: { provider: string; model: string } }[] = + []; const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { return principalsResponse(); @@ -132,6 +135,7 @@ describe("completeCredentialSetup", () => { hubUrl: "http://localhost:3000", userId: "user_1", userEmail: "alice@example.com", + provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, log: collector().log, @@ -140,7 +144,7 @@ describe("completeCredentialSetup", () => { seedCatalogCalls.push(args); }, seedTenantFn: async (args) => { - seedTenantCalls.push(args); + seedTenantCalls.push(args as never); }, }); @@ -152,5 +156,50 @@ describe("completeCredentialSetup", () => { }); expect(seedCatalogCalls).toHaveLength(1); expect(seedTenantCalls).toHaveLength(1); + expect(seedTenantCalls[0]?.model.provider).toBe("anthropic"); + }); + + test("a valid OpenAI key skips the Anthropic-only catalog but still seeds routines", async () => { + const seedCatalogCalls: unknown[] = []; + const seedTenantCalls: { model: { provider: string; model: string } }[] = + []; + const api: ApiCall = async (method, path) => { + if (method === "GET" && path === "/api/me/principals") { + return principalsResponse(); + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return tenantResponse(); + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + + const result = await completeCredentialSetup({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + provider: "openai", + apiKey: "sk-good", + pushWorkflow: noopPush, + log: collector().log, + testCredential: async () => ({ ok: true }), + seedCatalogFn: async (args) => { + seedCatalogCalls.push(args); + }, + seedTenantFn: async (args) => { + seedTenantCalls.push(args as never); + }, + }); + + expect(result).toEqual({ + kind: "seeded", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + workflows: ["echo", "assistant"], + }); + expect(seedCatalogCalls).toHaveLength(0); + expect(seedTenantCalls).toHaveLength(1); + expect(seedTenantCalls[0]?.model.provider).toBe("openai"); }); }); diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index 152686bd0..6ccd8baf6 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -62,7 +62,7 @@ describe("POST /provision", () => { }); }); -describe("POST /credential", () => { +describe("POST /credential/test", () => { test("an anonymous request is rejected before any credential is tested", async () => { const routes = createOnboardingRoutes({ hubUrl: "http://127.0.0.1:0", @@ -70,10 +70,13 @@ describe("POST /credential", () => { log: () => undefined, }); - const response = await routes.request("/credential", { + const response = await routes.request("/credential/test", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey: "sk-ant-whatever" }), + body: JSON.stringify({ + provider: "anthropic", + apiKey: "sk-ant-whatever", + }), }); expect(response.status).toBe(401); @@ -91,10 +94,77 @@ describe("POST /credential", () => { }); const app = mountAuthenticated(routes); - const response = await app.request("/credential", { + const response = await app.request("/credential/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "anthropic" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("invalid_request"); + }); + + test("an unsupported provider is rejected with a specific message", async () => { + const routes = createOnboardingRoutes({ + hubUrl: "http://127.0.0.1:0", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + const app = mountAuthenticated(routes); + + const response = await app.request("/credential/test", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "cohere", apiKey: "key" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("invalid_request"); + }); +}); + +describe("POST /complete", () => { + test("an anonymous request is rejected before anything is seeded", async () => { + const routes = createOnboardingRoutes({ + hubUrl: "http://127.0.0.1:0", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + + const response = await routes.request("/complete", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + provider: "anthropic", + apiKey: "sk-ant-whatever", + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("unauthorized"); + }); + + test("a missing provider is rejected with a specific message, no network call made", async () => { + const routes = createOnboardingRoutes({ + hubUrl: "http://127.0.0.1:0", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + const app = mountAuthenticated(routes); + + const response = await app.request("/complete", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ apiKey: "sk-ant-whatever" }), }); expect(response.status).toBe(400); From 05dda24027b3b39504bf77a6b4f4fd673fd72c8b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:40:39 -0700 Subject: [PATCH 5/6] Let onboarding pick Anthropic, OpenAI, or Google MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The credential step no longer assumes Anthropic: a provider picker drives which of Interchange's own adapters proves the key, and the default routines deploy against whichever provider and key the user chose. The onboarding-owned /credential endpoint is gone. Testing a key is now a side-effect-free /credential/test that stores nothing, and finishing setup is a /complete call that plants the credential through the hub's own POST /api/tenants/:id/credentials route via the existing seeding path — never a second, onboarding-specific way to store one. --- apps/web/src/onboarding.ts | 122 ++++++++++++-- apps/web/src/pages/onboarding-page.tsx | 70 ++++++-- packages/hub-client/src/credential-test.ts | 158 ++++++++++++++---- packages/hub-client/src/index.ts | 10 +- .../onboarding/src/complete-credential.ts | 79 +++++---- packages/onboarding/src/routes.ts | 59 ++++++- 6 files changed, 407 insertions(+), 91 deletions(-) diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index ac359717a..977d244dc 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -83,6 +83,34 @@ export async function triggerFirstLoginProvisioning(): Promise } } +export type CredentialProvider = "anthropic" | "openai" | "google-genai"; + +export const CREDENTIAL_PROVIDERS: readonly { + readonly id: CredentialProvider; + readonly label: string; + readonly keyConsoleUrl: string; + readonly keyHint: string; +}[] = [ + { + id: "anthropic", + label: "Anthropic", + keyConsoleUrl: "https://console.anthropic.com/settings/keys", + keyHint: "sk-ant-", + }, + { + id: "openai", + label: "OpenAI", + keyConsoleUrl: "https://platform.openai.com/api-keys", + keyHint: "sk-", + }, + { + id: "google-genai", + label: "Google", + keyConsoleUrl: "https://aistudio.google.com/apikey", + keyHint: "AIza", + }, +]; + const CredentialSeeded = type({ kind: "'seeded'", tenantSlug: "string", @@ -98,30 +126,90 @@ export type CredentialOutcome = | { readonly kind: "rejected"; readonly message: string } | { readonly kind: "error"; readonly message: string }; +async function postOnboarding( + path: string, + provider: CredentialProvider, + apiKey: string, +): Promise<{ readonly response: Response; readonly body: unknown }> { + const response = await fetch(`/api/onboarding/${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider, apiKey }), + }); + const body: unknown = await response.json().catch(() => null); + return { response, body }; +} + +function readErrorEnvelope( + status: number, + body: unknown, + verb: string, +): string { + const envelope = ErrorEnvelope(body); + return envelope instanceof type.errors + ? `The hub answered ${status} while ${verb}.` + : envelope.error.message; +} + +/** + * Proves a user's own key with a real call through the hub, without + * storing anything. Lets the wizard report success or a specific + * rejection before committing to seeding the bench. + */ +export async function testCredential( + provider: CredentialProvider, + apiKey: string, +): Promise< + { readonly ok: true } | { readonly ok: false; readonly message: string } +> { + try { + const { response, body } = await postOnboarding( + "credential/test", + provider, + apiKey, + ); + if (!response.ok) { + return { + ok: false, + message: readErrorEnvelope(response.status, body, "checking your key"), + }; + } + return { ok: true }; + } catch (cause) { + return { + ok: false, + message: cause instanceof Error ? cause.message : String(cause), + }; + } +} + /** - * Hands a user's own Anthropic key to the hub, which proves it with a - * real call before doing anything else with it, then seeds the - * caller's personal bench and confirms every default routine answers. - * A rejected key is reported by name (`"rejected"`) rather than folded - * into the same `"error"` bucket a broken hub call gets — the retry - * story is different for each. + * Hands a user's own key to the hub, which proves it with a real call + * before doing anything else with it, then seeds the caller's personal + * bench and confirms every default routine answers. The credential + * itself is stored through the hub's native `POST + * /api/tenants/:id/credentials` route — this call only tells the hub + * which provider and key to use, and reports the outcome. A rejected + * key is reported by name (`"rejected"`) rather than folded into the + * same `"error"` bucket a broken hub call gets — the retry story is + * different for each. */ export async function submitCredential( + provider: CredentialProvider, apiKey: string, ): Promise { try { - const response = await fetch("/api/onboarding/credential", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey }), - }); - const body: unknown = await response.json().catch(() => null); + const { response, body } = await postOnboarding( + "complete", + provider, + apiKey, + ); if (!response.ok) { - const envelope = ErrorEnvelope(body); - const message = - envelope instanceof type.errors - ? `The hub answered ${response.status} while checking your key.` - : envelope.error.message; + const message = readErrorEnvelope( + response.status, + body, + "setting up your bench", + ); return response.status === 422 ? { kind: "rejected", message } : { kind: "error", message }; diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 2510dfd4b..fb19c90fe 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -18,6 +18,7 @@ import { Input, PageShell, ProgressChecklist, + ProviderMark, Section, } from "@corbits/react-ui"; import type { ChecklistStep, WorkflowStep } from "@corbits/react-ui"; @@ -33,7 +34,12 @@ import { useCallback, useEffect, useState } from "react"; import type { FormEvent } from "react"; import { Link, useNavigate } from "../navigation"; -import { submitCredential, triggerFirstLoginProvisioning } from "../onboarding"; +import { + CREDENTIAL_PROVIDERS, + submitCredential, + triggerFirstLoginProvisioning, +} from "../onboarding"; +import type { CredentialProvider } from "../onboarding"; const GUIDANCE_CARDS = [ { @@ -125,9 +131,43 @@ function wizardSteps(phase: WizardState["phase"]): WorkflowStep[] { ]; } +function ProviderPicker({ + selected, + onSelect, + disabled, +}: { + readonly selected: CredentialProvider; + readonly onSelect: (provider: CredentialProvider) => void; + readonly disabled: boolean; +}) { + return ( +
+ {CREDENTIAL_PROVIDERS.map((provider) => ( + + ))} +
+ ); +} + export function OnboardingPage() { const navigate = useNavigate(); const [state, setState] = useState({ phase: "loading" }); + const [provider, setProvider] = useState("anthropic"); const [apiKey, setApiKey] = useState(""); const runProvisioning = useCallback(() => { @@ -150,7 +190,7 @@ export function OnboardingPage() { (event: FormEvent) => { event.preventDefault(); setState({ phase: "submitting" }); - void submitCredential(apiKey).then((outcome) => { + void submitCredential(provider, apiKey).then((outcome) => { if (outcome.kind === "seeded") { setState({ phase: "seeded", @@ -162,7 +202,7 @@ export function OnboardingPage() { } }); }, - [apiKey], + [provider, apiKey], ); if (state.phase === "loading") { @@ -219,7 +259,7 @@ export function OnboardingPage() {
@@ -233,23 +273,31 @@ export function OnboardingPage() { const submitting = state.phase === "submitting"; const error = state.phase === "credential" ? state.error : null; + const activeProvider = CREDENTIAL_PROVIDERS.find((p) => p.id === provider); return (
- + + setApiKey(event.target.value)} required @@ -258,13 +306,13 @@ export function OnboardingPage() { />

- Get a key from the Anthropic console + Get a key from the {activeProvider?.label} console {" "} - — it starts with sk-ant-. + — it starts with {activeProvider?.keyHint}.

{error !== null && ( Promise; -export type TestAnthropicCredentialArgs = { +export type TestProviderCredentialArgs = { + readonly provider: SupportedCredentialProvider; readonly apiKey: string; readonly fetchImpl?: FetchLike; }; -function anthropicErrorMessage(status: number, body: string): string { +type ProviderTestConfig = { + readonly displayName: string; + readonly createAdapter: AdapterFactory; + readonly baseURL: string; + /** A small, broadly-available model — good enough to prove a key works, + * never a claim about which model the caller's bench will actually use. */ + readonly probeModel: string; +}; + +const PROVIDER_TEST_CONFIG: Readonly< + Record +> = { + anthropic: { + displayName: "Anthropic", + createAdapter: createAnthropicAdapter, + baseURL: "https://api.anthropic.com", + probeModel: "claude-sonnet-5", + }, + openai: { + displayName: "OpenAI", + createAdapter: createOpenAIAdapter, + baseURL: "https://api.openai.com/v1", + probeModel: "gpt-4o-mini", + }, + "google-genai": { + displayName: "Google", + createAdapter: createGoogleGenAIAdapter, + baseURL: "https://generativelanguage.googleapis.com", + probeModel: "gemini-2.5-flash", + }, +}; + +export type ProviderModelSource = { + readonly provider: SupportedCredentialProvider; + readonly model: string; + readonly baseURL: string; +}; + +/** The default model and endpoint a freshly-added credential deploys + * workflows against, before the person who added it ever picks a + * different one. */ +export function providerModelSource( + provider: SupportedCredentialProvider, +): ProviderModelSource { + const config = PROVIDER_TEST_CONFIG[provider]; + return { provider, model: config.probeModel, baseURL: config.baseURL }; +} + +export function supportedCredentialProviders(): readonly { + readonly id: SupportedCredentialProvider; + readonly displayName: string; +}[] { + return ( + Object.entries(PROVIDER_TEST_CONFIG) as [ + SupportedCredentialProvider, + ProviderTestConfig, + ][] + ).map(([id, config]) => ({ id, displayName: config.displayName })); +} + +function providerErrorMessage( + displayName: string, + status: number, + body: string, +): string { const parsed: unknown = JSON.parse(body); if ( typeof parsed === "object" && @@ -37,23 +112,49 @@ function anthropicErrorMessage(status: number, body: string): string { ) { return (parsed as { error: { message: string } }).error.message; } - return `Anthropic rejected the request with status ${status}`; + return `${displayName} rejected the request with status ${status}`; +} + +/** Substitutes the real key into whichever sentinel the adapter's built + * headers carry, without knowing in advance which header that provider + * uses — the sentinel values are the adapter's own exported contract for + * exactly this substitution. */ +function injectCredential( + headers: Record, + apiKey: string, +): Headers { + const result = new Headers(); + for (const [name, value] of Object.entries(headers)) { + if (value === CREDENTIAL_SENTINEL) result.set(name, apiKey); + else if (value === BEARER_CREDENTIAL_SENTINEL) + result.set(name, `Bearer ${apiKey}`); + else result.set(name, value); + } + return result; +} + +function carriesACredentialSentinel(headers: Record): boolean { + return Object.values(headers).some( + (value) => + value === CREDENTIAL_SENTINEL || value === BEARER_CREDENTIAL_SENTINEL, + ); } /** - * Fires the smallest real Anthropic call a credential can be validated - * with — a one-token completion against the same model the onboarding - * catalog seeds — and reports whether the key works. Never stores or + * Fires the smallest real call a credential can be validated with — a + * one-token completion — against whichever provider the caller picked, + * through that provider's own `@intx/inference` adapter. Never stores or * logs the key; the caller owns that decision once this says `ok`. */ -export async function testAnthropicCredential( - args: TestAnthropicCredentialArgs, +export async function testProviderCredential( + args: TestProviderCredentialArgs, ): Promise { + const config = PROVIDER_TEST_CONFIG[args.provider]; const doFetch = args.fetchImpl ?? fetch; - const adapter = createAnthropicAdapter({ + const adapter = config.createAdapter({ sourceId: "onboarding-credential-test", - provider: catalogProvider.name, - model: catalogModel.canonicalName, + provider: args.provider, + model: config.probeModel, }); const request = adapter.buildRequest( @@ -64,23 +165,21 @@ export async function testAnthropicCredential( timestamp: Date.now(), }, ], - catalogModel.canonicalName, + config.probeModel, { maxTokens: 1 }, ); - if (request.headers["x-api-key"] !== CREDENTIAL_SENTINEL) { + if (!carriesACredentialSentinel(request.headers)) { return { ok: false, - message: - "internal error: the Anthropic adapter no longer uses the credential sentinel this test relies on", + message: `internal error: the ${config.displayName} adapter no longer uses a credential sentinel this test relies on`, }; } - const headers = new Headers(request.headers); - headers.set("x-api-key", args.apiKey); + const headers = injectCredential(request.headers, args.apiKey); let response: Response; try { - response = await doFetch(`${catalogProvider.baseURL}${request.url}`, { + response = await doFetch(`${config.baseURL}${request.url}`, { method: "POST", headers, body: request.body, @@ -90,13 +189,16 @@ export async function testAnthropicCredential( ok: false, message: cause instanceof Error - ? `Could not reach Anthropic: ${cause.message}` - : `Could not reach Anthropic: ${String(cause)}`, + ? `Could not reach ${config.displayName}: ${cause.message}` + : `Could not reach ${config.displayName}: ${String(cause)}`, }; } const body = await response.text(); if (response.ok) return { ok: true }; - return { ok: false, message: anthropicErrorMessage(response.status, body) }; + return { + ok: false, + message: providerErrorMessage(config.displayName, response.status, body), + }; } diff --git a/packages/hub-client/src/index.ts b/packages/hub-client/src/index.ts index 313402641..5f35ad0e5 100644 --- a/packages/hub-client/src/index.ts +++ b/packages/hub-client/src/index.ts @@ -27,8 +27,14 @@ export { catalogProvider, } from "./catalog-seed-data"; export { createGitWorkflowPusher } from "./workflow-push"; -export { testAnthropicCredential } from "./credential-test"; +export { + providerModelSource, + supportedCredentialProviders, + testProviderCredential, +} from "./credential-test"; export type { CredentialTestResult, - TestAnthropicCredentialArgs, + ProviderModelSource, + SupportedCredentialProvider, + TestProviderCredentialArgs, } from "./credential-test"; diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index 86be17945..2e46dc1ad 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -1,26 +1,29 @@ // The guided credential step of first-run: a signed-in user who reached -// onboarding with no seed model configured pastes their own Anthropic -// key here. The key is proven with a real call before anything is -// stored (see `@workbench/hub-client`'s `testAnthropicCredential`, -// which itself goes through `@intx/inference`'s own Anthropic adapter), -// and only once it's proven does this seed the caller's own personal -// bench — the same `seedCatalog` + `seedTenant` the first-login hook -// runs when a hub-owned key is configured, so a self-served key and an -// operator-configured one land the same bench. +// onboarding with no seed model configured picks a provider — Anthropic, +// OpenAI, or Google — and pastes their own key. The key is proven with +// a real call before anything is stored (see `@workbench/hub-client`'s +// `testProviderCredential`, which itself goes through `@intx/inference`'s +// own adapter for that provider), and only once it's proven does this +// seed the caller's own personal bench — the same `seedCatalog` + +// `seedTenant` the first-login hook runs when a hub-owned key is +// configured, so a self-served key and an operator-configured one land +// the same bench. Both plant the credential through the hub's native +// `POST /api/tenants/:id/credentials` route (see `seedCatalog`'s +// `ensureCredential`) — this module never stores a secret itself. import { PrincipalSummary, TenantResponse, paginatedSchema } from "@intx/types"; import { DEFAULT_WORKFLOWS, parseAs, + providerModelSource, seedCatalog, seedTenant, - testAnthropicCredential, - catalogProvider, - catalogModel, + testProviderCredential, type ApiCall, type SeedCatalogArgs, type SeedTenantArgs, - type TestAnthropicCredentialArgs, + type SupportedCredentialProvider, + type TestProviderCredentialArgs, type WorkflowPusher, } from "@workbench/hub-client"; import { personalTenantSlug } from "./provision"; @@ -41,12 +44,13 @@ export type CompleteCredentialArgs = { hubUrl: string; userId: string; userEmail: string; + provider: SupportedCredentialProvider; apiKey: string; pushWorkflow: WorkflowPusher; log: (line: string) => void; testCredential?: ( - args: TestAnthropicCredentialArgs, - ) => ReturnType; + args: TestProviderCredentialArgs, + ) => ReturnType; seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; seedTenantFn?: (args: SeedTenantArgs) => ReturnType; }; @@ -74,19 +78,27 @@ async function findPersonalTenant( } /** - * Proves an onboarding user's Anthropic key with a real call, then - * seeds their own personal bench with it. A bad key never reaches the - * tenant at all — the credential test runs first and short-circuits - * everything else. + * Proves an onboarding user's key with a real call against the provider + * they picked, then seeds their own personal bench with it. A bad key + * never reaches the tenant at all — the credential test runs first and + * short-circuits everything else. The tenant's model catalog (the + * browsable list a channel picks a model from) is only planted for + * Anthropic today; the other providers still get their credential + * stored and their default routines deployed and confirmed, since + * `DEFAULT_WORKFLOWS` picks its inference source per source, not from + * the catalog. */ export async function completeCredentialSetup( args: CompleteCredentialArgs, ): Promise { - const testCredential = args.testCredential ?? testAnthropicCredential; + const testCredential = args.testCredential ?? testProviderCredential; const runSeedCatalog = args.seedCatalogFn ?? seedCatalog; const runSeedTenant = args.seedTenantFn ?? seedTenant; - const test = await testCredential({ apiKey: args.apiKey }); + const test = await testCredential({ + provider: args.provider, + apiKey: args.apiKey, + }); if (!test.ok) return { kind: "invalid-credential", message: test.message }; const expectedSlug = personalTenantSlug(args.userEmail, args.userId); @@ -105,14 +117,21 @@ export async function completeCredentialSetup( "tenant response", ); - await runSeedCatalog({ - api: args.api, - cookies: args.cookies, - tenantId: own.tenantId, - apiKey: args.apiKey, - log: args.log, - }); + if (args.provider === "anthropic") { + await runSeedCatalog({ + api: args.api, + cookies: args.cookies, + tenantId: own.tenantId, + apiKey: args.apiKey, + log: args.log, + }); + } else { + args.log( + `bench ${own.tenantSlug}: skipping the browsable model catalog for provider ${args.provider}; only Anthropic is catalogued today`, + ); + } + const modelSource = providerModelSource(args.provider); await runSeedTenant({ api: args.api, cookies: args.cookies, @@ -123,9 +142,9 @@ export async function completeCredentialSetup( domain: tenant.domain, }, model: { - provider: catalogProvider.name, - model: catalogModel.canonicalName, - baseURL: catalogProvider.baseURL, + provider: modelSource.provider, + model: modelSource.model, + baseURL: modelSource.baseURL, apiKey: args.apiKey, }, pushWorkflow: args.pushWorkflow, diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index fd46d0659..d27259743 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -7,7 +7,10 @@ import type { AppEnv } from "@intx/hub-api"; import { createHubAPI, + supportedCredentialProviders, + testProviderCredential, type ModelSource, + type SupportedCredentialProvider, type WorkflowPusher, } from "@workbench/hub-client"; import { Hono } from "hono"; @@ -15,7 +18,15 @@ import { type } from "arktype"; import { provisionPersonalTenantIfNeeded } from "./provision"; import { completeCredentialSetup } from "./complete-credential"; -const SubmitCredential = type({ apiKey: "string > 0" }); +const PROVIDER_IDS = supportedCredentialProviders().map((p) => p.id) as [ + SupportedCredentialProvider, + ...SupportedCredentialProvider[], +]; + +const SubmitCredential = type({ + provider: type.enumerated(...PROVIDER_IDS), + apiKey: "string > 0", +}); export type CreateOnboardingRoutesDeps = { hubUrl: string; @@ -87,7 +98,48 @@ export function createOnboardingRoutes( } }); - app.post("/credential", async (c) => { + // A pure test: proves a key against the provider's real API before the + // caller commits to anything. No credential is stored here — storage + // and the rest of seeding only happen from `/complete`, and even that + // stores through the hub's own `POST /api/tenants/:id/credentials` + // route (see `complete-credential.ts`), never by reimplementing it. + app.post("/credential/test", async (c) => { + const user = c.get("user"); + if (!user) { + return c.json( + { error: { code: "unauthorized", message: "Authentication required" } }, + 401, + ); + } + + const body: unknown = await c.req.json().catch(() => null); + const parsed = SubmitCredential(body); + if (parsed instanceof type.errors) { + return c.json( + { + error: { + code: "invalid_request", + message: `A provider and an API key are required: ${parsed.summary}`, + }, + }, + 400, + ); + } + + const result = await testProviderCredential({ + provider: parsed.provider, + apiKey: parsed.apiKey, + }); + if (!result.ok) { + return c.json( + { error: { code: "invalid_credential", message: result.message } }, + 422, + ); + } + return c.json({ ok: true }, 200); + }); + + app.post("/complete", async (c) => { const user = c.get("user"); if (!user) { return c.json( @@ -103,7 +155,7 @@ export function createOnboardingRoutes( { error: { code: "invalid_request", - message: `An Anthropic API key is required: ${parsed.summary}`, + message: `A provider and an API key are required: ${parsed.summary}`, }, }, 400, @@ -118,6 +170,7 @@ export function createOnboardingRoutes( hubUrl: deps.hubUrl, userId: user.id, userEmail: user.email, + provider: parsed.provider, apiKey: parsed.apiKey, pushWorkflow: deps.pushWorkflow, log: deps.log, From a544df9692e0ce83f68a25bf5e68d4f7b2082b90 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 15:40:57 -0700 Subject: [PATCH 6/6] Update docs: onboarding supports Anthropic, OpenAI, and Google Notes the provider picker and that the browsable model catalog is still Anthropic-only, even though credentials and routines work for any of the three. --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b58da1668..2f1a46060 100644 --- a/README.md +++ b/README.md @@ -72,9 +72,12 @@ inference errors until you set it and re-run `bun run seed`. Leaving `ANTHROPIC_API_KEY` unset doesn't just apply to the administrator account: anyone who signs up gets a personal bench with no default routines deployed, and first-run tells them exactly that. Onboarding walks them -through pasting their own Anthropic key, proves it with a real call before -storing anything, then deploys and confirms the default routines on the -spot — no separate `bun run seed` step, no docs to read. +through picking a provider — Anthropic, OpenAI, or Google — and pasting +their own key, proves it with a real call before storing anything, then +deploys and confirms the default routines on the spot — no separate +`bun run seed` step, no docs to read. The tenant's browsable model catalog +is only planted for Anthropic today; the other providers still get a +credential and working routines, just not a catalog entry yet. ### OAuth sign-in