Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 151 additions & 0 deletions apps/web/src/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,154 @@ export async function triggerFirstLoginProvisioning(): Promise<ProvisionOutcome>
};
}
}

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<CredentialOutcome> {
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),
};
}
}
Loading
Loading