diff --git a/README.md b/README.md index 4249320d7..2f1a46060 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,16 @@ 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 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 Email/password sign-in always works. To let people sign in with an diff --git a/apps/web/src/onboarding.ts b/apps/web/src/onboarding.ts index aa15f4c63..977d244dc 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -82,3 +82,154 @@ 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", + 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 }; + +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 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, body } = await postOnboarding( + "complete", + provider, + apiKey, + ); + if (!response.ok) { + const message = readErrorEnvelope( + response.status, + body, + "setting up your bench", + ); + 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..fb19c90fe 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,33 @@ import { CardDescription, CardHeader, CardTitle, + EmptyState, + HorizontalStepper, + Input, PageShell, + ProgressChecklist, + ProviderMark, 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 { + CREDENTIAL_PROVIDERS, + submitCredential, + triggerFirstLoginProvisioning, +} from "../onboarding"; +import type { CredentialProvider } from "../onboarding"; const GUIDANCE_CARDS = [ { @@ -47,27 +68,265 @@ 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", + }, + ]; +} + +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(() => { + 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(provider, apiKey).then((outcome) => { + if (outcome.kind === "seeded") { + setState({ + phase: "seeded", + tenantSlug: outcome.tenantSlug, + workflows: outcome.workflows, + }); + } else { + setState({ phase: "credential", error: outcome.message }); + } + }); + }, + [provider, 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; + const activeProvider = CREDENTIAL_PROVIDERS.find((p) => p.id === provider); + 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 {activeProvider?.label} console + {" "} + — it starts with {activeProvider?.keyHint}. +

+ {error !== null && ( + } + title="That key didn't work" + description={error} + /> + )} + +
); diff --git a/apps/web/test/onboarding.test.tsx b/apps/web/test/onboarding.test.tsx index 6a452c45b..de2bcf984 100644 --- a/apps/web/test/onboarding.test.tsx +++ b/apps/web/test/onboarding.test.tsx @@ -8,7 +8,11 @@ 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, + testCredential, + triggerFirstLoginProvisioning, +} from "../src/onboarding"; import type { SessionState } from "../src/session"; const realFetch = globalThis.fetch; @@ -70,6 +74,79 @@ 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 () => + json( + { error: { code: "invalid_credential", message: "invalid x-api-key" } }, + 422, + )) as unknown as typeof fetch; + + 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 () => { + 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; + + 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 () => { + globalThis.fetch = (async () => { + throw new Error("connection refused"); + }) as unknown as typeof fetch; + + 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"); + }); +}); + describe("App with a provisioning error", () => { test("blocks the shell with a retry action instead of rendering it", () => { const markup = renderToStaticMarkup( 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..20bf659ca --- /dev/null +++ b/packages/hub-client/src/credential-test.ts @@ -0,0 +1,204 @@ +// 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 provider 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, for +// whichever provider the user picked. Only the transport (one `fetch`, +// no streaming consumed) and the pass/fail verdict belong to workbench. + +import { + createAnthropicAdapter, + createGoogleGenAIAdapter, + createOpenAIAdapter, +} from "@intx/inference/providers"; +import { + BEARER_CREDENTIAL_SENTINEL, + CREDENTIAL_SENTINEL, +} from "@intx/inference"; +import type { AdapterFactory } from "@intx/inference"; + +export type SupportedCredentialProvider = + "anthropic" | "openai" | "google-genai"; + +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 TestProviderCredentialArgs = { + readonly provider: SupportedCredentialProvider; + readonly apiKey: string; + readonly fetchImpl?: FetchLike; +}; + +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" && + 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 `${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 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 testProviderCredential( + args: TestProviderCredentialArgs, +): Promise { + const config = PROVIDER_TEST_CONFIG[args.provider]; + const doFetch = args.fetchImpl ?? fetch; + const adapter = config.createAdapter({ + sourceId: "onboarding-credential-test", + provider: args.provider, + model: config.probeModel, + }); + + const request = adapter.buildRequest( + [ + { + role: "user", + content: [{ type: "text", text: "Reply with the word ok." }], + timestamp: Date.now(), + }, + ], + config.probeModel, + { maxTokens: 1 }, + ); + + if (!carriesACredentialSentinel(request.headers)) { + return { + ok: false, + message: `internal error: the ${config.displayName} adapter no longer uses a credential sentinel this test relies on`, + }; + } + const headers = injectCredential(request.headers, args.apiKey); + + let response: Response; + try { + response = await doFetch(`${config.baseURL}${request.url}`, { + method: "POST", + headers, + body: request.body, + }); + } catch (cause) { + return { + ok: false, + message: + cause instanceof Error + ? `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: providerErrorMessage(config.displayName, response.status, body), + }; +} diff --git a/packages/hub-client/src/index.ts b/packages/hub-client/src/index.ts index d8b5be1c7..5f35ad0e5 100644 --- a/packages/hub-client/src/index.ts +++ b/packages/hub-client/src/index.ts @@ -27,3 +27,14 @@ export { catalogProvider, } from "./catalog-seed-data"; export { createGitWorkflowPusher } from "./workflow-push"; +export { + providerModelSource, + supportedCredentialProviders, + testProviderCredential, +} from "./credential-test"; +export type { + CredentialTestResult, + ProviderModelSource, + SupportedCredentialProvider, + TestProviderCredentialArgs, +} from "./credential-test"; 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..244e5cc42 --- /dev/null +++ b/packages/hub-client/test/credential-test.test.ts @@ -0,0 +1,101 @@ +// Real inference-shaped calls, fake network: these tests exercise +// `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 { + supportedCredentialProviders, + testProviderCredential, + type FetchLike, + type SupportedCredentialProvider, +} from "../src/credential-test"; + +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, + }); + + 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 }, + ); + + const result = await testProviderCredential({ + provider, + apiKey: "test-wrong-key", + fetchImpl, + }); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.message).toContain("invalid api key"); + }); + + test(`${provider}: reports a transport failure without pretending the key is bad`, async () => { + const fetchImpl: FetchLike = async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }; + + const result = await testProviderCredential({ + provider, + apiKey: "test-real-key", + fetchImpl, + }); + + 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 }); + }; + + await testProviderCredential({ + provider, + apiKey: "test-secret-key", + fetchImpl, + }); + + 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/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts new file mode 100644 index 000000000..2e46dc1ad --- /dev/null +++ b/packages/onboarding/src/complete-credential.ts @@ -0,0 +1,161 @@ +// The guided credential step of first-run: a signed-in user who reached +// 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, + testProviderCredential, + type ApiCall, + type SeedCatalogArgs, + type SeedTenantArgs, + type SupportedCredentialProvider, + type TestProviderCredentialArgs, + 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; + provider: SupportedCredentialProvider; + apiKey: string; + pushWorkflow: WorkflowPusher; + log: (line: string) => void; + testCredential?: ( + args: TestProviderCredentialArgs, + ) => 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 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 ?? testProviderCredential; + const runSeedCatalog = args.seedCatalogFn ?? seedCatalog; + const runSeedTenant = args.seedTenantFn ?? seedTenant; + + 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); + 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", + ); + + 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, + hubUrl: args.hubUrl, + tenant: { + tenantId: own.tenantId, + principalId: own.principalId, + domain: tenant.domain, + }, + model: { + provider: modelSource.provider, + model: modelSource.model, + baseURL: modelSource.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..d27259743 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -7,11 +7,26 @@ 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"; +import { type } from "arktype"; import { provisionPersonalTenantIfNeeded } from "./provision"; +import { completeCredentialSetup } from "./complete-credential"; + +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; @@ -83,5 +98,118 @@ export function createOnboardingRoutes( } }); + // 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( + { 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 cookies = cookiesFromHeader(c.req.header("cookie")); + try { + const result = await completeCredentialSetup({ + api, + cookies, + hubUrl: deps.hubUrl, + userId: user.id, + userEmail: user.email, + provider: parsed.provider, + 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; } diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts new file mode 100644 index 000000000..b59fa935c --- /dev/null +++ b/packages/onboarding/test/complete-credential.test.ts @@ -0,0 +1,205 @@ +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", + provider: "anthropic", + 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", + provider: "anthropic", + apiKey: "sk-ant-good", + pushWorkflow: noopPush, + log: collector().log, + testCredential: async () => ({ ok: true }), + }); + + expect(result).toEqual({ kind: "no-personal-bench" }); + }); + + test("a valid Anthropic key seeds the catalog, the tenant, and reports what ran", 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: "anthropic", + 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 as never); + }, + }); + + expect(result).toEqual({ + kind: "seeded", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + workflows: ["echo", "assistant"], + }); + 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 bc32f22fb..6ccd8baf6 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -61,3 +61,116 @@ describe("POST /provision", () => { expect(body.error.code).toBe("unauthorized"); }); }); + +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", + pushWorkflow: async () => "pushed", + log: () => undefined, + }); + + const response = await routes.request("/credential/test", { + 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 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/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({ apiKey: "sk-ant-whatever" }), + }); + + expect(response.status).toBe(400); + const body = (await response.json()) as { + error: { code: string; message: string }; + }; + expect(body.error.code).toBe("invalid_request"); + }); +});