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
21 changes: 10 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -136,23 +136,22 @@ HUB_STATIC_DIR=../web/dist
# in-room connect-github card) — a SEPARATE OAuth App from GITHUB_CLIENT_ID
# above, which only signs people in to Workbench itself. Create a second
# OAuth app at https://github.com/settings/developers, with an
# authorization callback URL of
# <BASE_URL>/api/tenants/:tenantId/connections/github/callback and the
# "repo" scope. Leave both unset and Connect GitHub falls back to a
# guided personal-access-token paste — no dead end either way. NOTE: as of
# CL-6386, `@workbench/connections`' generic oauth-pkce/oauth-code route
# factory (`createOAuthConnectRoutes`) is exported but not yet mounted in
# `apps/hub` — see CL-6386's PR report. Setting these two vars registers
# the app and flips the Plugins UI over to it, but the redirect itself
# will not complete until that mount lands (a pre-existing gap that also
# affects OpenRouter/Hugging Face's own hosted-connect buttons).
# authorization callback URL of <BASE_URL>/api/tenants/ — GitHub matches
# any redirect under that prefix, and the flow's actual callback is the
# tenant-scoped <BASE_URL>/api/tenants/<tenant-id>/connections/oauth/github/callback
# (CL-6394). The connect asks for the "repo" scope. Leave both unset and
# Connect GitHub falls back to a guided personal-access-token paste — no
# dead end either way.
# GITHUB_APP_CLIENT_ID=
# GITHUB_APP_CLIENT_SECRET=

# Onboarding's Hugging Face connect card (a public OAuth app — no
# secret): create one at https://huggingface.co/settings/applications/new
# with a redirect URI of <BASE_URL>/api/onboarding/oauth/huggingface/callback
# and scope "openid inference-api". See
# (first-login onboarding) — the settings/plugins Connect buttons ride
# the tenant-scoped <BASE_URL>/api/tenants/<tenant-id>/connections/oauth/huggingface/callback
# instead (CL-6394), so register that shape too if your HF app supports
# it — and scope "openid inference-api". See
# docs/onboarding-huggingface-connect.md for the full setup. Leave unset
# and Hugging Face stays available only as a paste-a-token provider card.
# HUGGINGFACE_OAUTH_CLIENT_ID=
Expand Down
6 changes: 1 addition & 5 deletions apps/hub/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1879,7 +1879,7 @@ export async function createHub(config: HubConfig) {
// through to `createTenantConnectCredential`.
app.route(
`${TENANT_PREFIX}/connections/oauth`,
createOAuthConnectRoutes({
createOAuthConnectRoutes<TenantEnv>({
hubUrl: config.baseUrl,
log: (line) => log.info`${line}`,
credentialCipher,
Expand Down Expand Up @@ -3121,10 +3121,6 @@ export async function createHub(config: HubConfig) {
onboardingDeps.seedModel = config.seedModel;
if (config.huggingfaceOAuthClientId !== undefined)
onboardingDeps.huggingfaceClientId = config.huggingfaceOAuthClientId;
if (config.githubAppClientId !== undefined)
onboardingDeps.githubAppClientId = config.githubAppClientId;
if (config.githubAppClientSecret !== undefined)
onboardingDeps.githubAppClientSecret = config.githubAppClientSecret;

app.route("/api/onboarding", createOnboardingRoutes(onboardingDeps));

Expand Down
14 changes: 0 additions & 14 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,6 @@ select:disabled,
cursor: not-allowed;
}

.app-mark {
height: 1.375rem;
width: auto;
flex-shrink: 0;
color: var(--primary);
}

.app-boot-frame {
position: relative;
display: flex;
Expand Down Expand Up @@ -1828,13 +1821,6 @@ select:disabled,
cursor: pointer;
}

.app-wordmark {
font-size: 0.875rem;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
}

/* Onboarding: full-screen guided wizard, gated above the shell entirely
(see app.tsx) so nothing implying an existing workbench — sidebar,
bench dock — is ever on screen while it runs. Same two-column shape as
Expand Down
1 change: 1 addition & 0 deletions packages/connections/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"exports": {
".": "./src/index.ts",
"./registry": "./src/registry.ts",
"./persist-credential": "./src/persist-credential.ts",
"./plugins": "./src/plugins.ts",
"./provider-health": "./src/provider-health.ts",
"./mcp-presets": "./src/mcp-presets.ts"
Expand Down
6 changes: 6 additions & 0 deletions packages/connections/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export {
createTenantConnectCredential,
type CreateTenantConnectCredentialDeps,
} from "./oauth-tenant-connect";
export {
isInferenceProvider,
persistConnectorCredential,
type PersistConnectorCredentialArgs,
type PersistConnectorCredentialFns,
} from "./persist-credential";
export {
CONNECT_STATE_TTL_MS as OPENROUTER_CONNECT_STATE_TTL_MS,
exchangeCodeForKey,
Expand Down
25 changes: 13 additions & 12 deletions packages/connections/src/oauth-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ export type OAuthStoreOutcome =
| { readonly kind: "invalid-credential"; readonly message: string }
| { readonly kind: "no-personal-bench" };

export type CreateOAuthConnectRoutesDeps = {
export type CreateOAuthConnectRoutesDeps<E extends AppEnv = AppEnv> = {
readonly hubUrl: string;
readonly log: (line: string) => void;
/** Seals the PKCE+state cookie parked between `/start` and
Expand All @@ -156,13 +156,14 @@ export type CreateOAuthConnectRoutesDeps = {
* Required: without a caller-supplied store step, a successful
* exchange would have nowhere to land. */
readonly connectCredential: (args: {
/** Given so a tenant-scoped caller (mounted inside the platform's
* tenant middleware, same as `afterConnected` below) can read
* `c.get("tenant")`/`c.get("principal")` directly instead of
* re-deriving them — see `createTenantConnectCredential` in
/** Typed by the factory's own env parameter, so a tenant-scoped
* caller (`E = TenantEnv`, mounted inside the platform's tenant
* middleware) reads `c.get("tenant")`/`c.get("principal")` directly
* with no cast — see `createTenantConnectCredential` in
* `./oauth-tenant-connect.ts`. A caller with no tenant middleware
* (`packages/onboarding`'s own mount) is free to ignore it. */
c: Context;
* (`packages/onboarding`'s own mount, `E = AppEnv`) is free to
* ignore it. */
c: Context<E>;
connectorId: string;
userId: string;
userEmail: string;
Expand All @@ -185,7 +186,7 @@ export type CreateOAuthConnectRoutesDeps = {
* pending-seed sealing lives here, entirely outside this package.
* Given the Hono `Context` directly so it can set its own cookie. */
readonly afterConnected?: (args: {
c: Context;
c: Context<E>;
connectorId: string;
userId: string;
apiKey: string;
Expand All @@ -207,10 +208,10 @@ export type CreateOAuthConnectRoutesDeps = {
const CONNECT_STATE_TTL_MS = 10 * 60 * 1000;
const CONNECT_START_RATE_LIMIT_MS = 10_000;

export function createOAuthConnectRoutes(
deps: CreateOAuthConnectRoutesDeps,
): Hono<AppEnv> {
const app = new Hono<AppEnv>();
export function createOAuthConnectRoutes<E extends AppEnv = AppEnv>(
deps: CreateOAuthConnectRoutesDeps<E>,
): Hono<E> {
const app = new Hono<E>();
const registry = deps.registry ?? CONNECTOR_REGISTRY;
const oauthEnv = deps.oauthEnv ?? {};
const defaultReturnPath = deps.defaultReturnPath ?? "/onboarding";
Expand Down
148 changes: 50 additions & 98 deletions packages/connections/src/oauth-tenant-connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,87 +7,52 @@
// inside the platform's tenant middleware — the same one
// `createConnectionRoutes` and `createMcpOAuthRoutes` (#115) run
// inside — so `c.get("tenant")`/`c.get("principal")` are already
// resolved and this never re-derives a tenant of its own.
// resolved, typed by the factory's own `TenantEnv` parameter rather
// than a cast.
//
// Persists exactly the way `routes.ts`'s `POST /:connectorId/complete`
// does: `ensureProvider` + `ensureCredential`, then `seedCatalog` for an
// inference connector so a just-connected provider's models are
// launchable immediately, not just stored. The credential is already
// proven by the OAuth exchange itself, so there is no separate probe
// step here (unlike `/complete`'s pasted-key path, which has nothing
// else vouching for the secret).
import type { Context } from "hono";
// Persists through the one shared sequence every connect surface runs
// (`./persist-credential.ts`): provider + credential rows always, the
// curated model catalog only for an inference connector — a
// non-inference connector (GitHub) stores its token and stops there.
// The credential is already proven by the OAuth exchange itself, so
// there is no separate probe step here (unlike `/complete`'s pasted-key
// path, which has nothing else vouching for the secret).
import type { TenantEnv } from "@intx/hub-api";
import {
createHubAPI,
ensureCredential,
ensureProvider,
PROVIDER_TEST_CONFIG,
seedCatalog,
type ApiCall,
type EnsureCredentialArgs,
type EnsureProviderArgs,
type SeedCatalogArgs,
type SupportedCredentialProvider,
} from "@workbench/hub-client";
import { createHubAPI } from "@workbench/hub-client";
import type { ConnectorDescriptor } from "./descriptor";
import type { ProviderHealthStore } from "./provider-health";
import { CONNECTOR_REGISTRY } from "./registry";
import type { CreateOAuthConnectRoutesDeps } from "./oauth-routes";
import {
persistConnectorCredential,
type PersistConnectorCredentialFns,
} from "./persist-credential";

export type CreateTenantConnectCredentialDeps = {
readonly hubUrl: string;
readonly log: (line: string) => void;
/** Test-only override, matching every other route factory here. */
readonly registry?: Readonly<Record<string, ConnectorDescriptor>>;
/** Cleared on a successful connect, same store `createConnectionRoutes`'
* `/complete` and `GET /provider-health` share (CL-6092). */
readonly providerHealth?: ProviderHealthStore;
/** Test-only override, matching `routes.ts`'s own seam — lets this
* module's own test prove the persist/seed sequencing without
* reaching for module mocking or a real hub HTTP server. */
readonly ensureProviderFn?: (
api: ApiCall,
cookies: string[],
args: EnsureProviderArgs,
log: (line: string) => void,
) => ReturnType<typeof ensureProvider>;
readonly ensureCredentialFn?: (
api: ApiCall,
cookies: string[],
args: EnsureCredentialArgs,
log: (line: string) => void,
) => ReturnType<typeof ensureCredential>;
readonly seedCatalogFn?: (
args: SeedCatalogArgs,
) => ReturnType<typeof seedCatalog>;
};

function isInferenceProvider(id: string): id is SupportedCredentialProvider {
return Object.hasOwn(PROVIDER_TEST_CONFIG, id);
}
export type CreateTenantConnectCredentialDeps =
PersistConnectorCredentialFns & {
readonly hubUrl: string;
readonly log: (line: string) => void;
/** Test-only override, matching every other route factory here. */
readonly registry?: Readonly<Record<string, ConnectorDescriptor>>;
/** Cleared on a successful connect, same store `createConnectionRoutes`'
* `/complete` and `GET /provider-health` share (CL-6092). */
readonly providerHealth?: ProviderHealthStore;
};

/**
* Builds the `connectCredential` dep `createOAuthConnectRoutes` needs,
* scoped to whatever tenant the request's own middleware already
* resolved. `args.c` is cast to `Context<TenantEnv>` — safe only
* because this is wired exclusively into a mount reached through the
* platform's tenant middleware (see this module's own header); a caller
* mounting outside that middleware must not use this.
* Builds the `connectCredential` dep a `TenantEnv`-typed
* `createOAuthConnectRoutes` mount needs, scoped to whatever tenant the
* request's own middleware already resolved.
*/
export function createTenantConnectCredential(
deps: CreateTenantConnectCredentialDeps,
): CreateOAuthConnectRoutesDeps["connectCredential"] {
): CreateOAuthConnectRoutesDeps<TenantEnv>["connectCredential"] {
const api = createHubAPI(deps.hubUrl);
const registry = deps.registry ?? CONNECTOR_REGISTRY;
const runEnsureProvider = deps.ensureProviderFn ?? ensureProvider;
const runEnsureCredential = deps.ensureCredentialFn ?? ensureCredential;
const runSeedCatalog = deps.seedCatalogFn ?? seedCatalog;

return async (args) => {
const c = args.c as Context<TenantEnv>;
const tenant = c.get("tenant");
const principal = c.get("principal");
const tenant = args.c.get("tenant");
const principal = args.c.get("principal");
const descriptor = registry[args.connectorId];
if (descriptor === undefined) {
return {
Expand All @@ -97,40 +62,27 @@ export function createTenantConnectCredential(
}

try {
const providerId = await runEnsureProvider(
const persistArgs: Parameters<typeof persistConnectorCredential>[0] = {
api,
args.cookies,
{
tenantId: tenant.id,
name: descriptor.id,
plugin: descriptor.credentialPlugin,
},
deps.log,
);
await runEnsureCredential(
api,
args.cookies,
{
tenantId: tenant.id,
providerId,
name: descriptor.displayName,
secret: args.apiKey,
type: "api_key",
verified: true,
},
deps.log,
);
if (isInferenceProvider(descriptor.id)) {
await runSeedCatalog({
api,
cookies: args.cookies,
tenantId: tenant.id,
log: deps.log,
provider: descriptor.id,
apiKey: args.apiKey,
credentialVerified: true,
});
}
cookies: args.cookies,
tenantId: tenant.id,
descriptor,
secret: args.apiKey,
log: deps.log,
...(args.credentialMetadata !== undefined
? { credentialMetadata: args.credentialMetadata }
: {}),
...(deps.ensureProviderFn !== undefined
? { ensureProviderFn: deps.ensureProviderFn }
: {}),
...(deps.ensureCredentialFn !== undefined
? { ensureCredentialFn: deps.ensureCredentialFn }
: {}),
...(deps.seedCatalogFn !== undefined
? { seedCatalogFn: deps.seedCatalogFn }
: {}),
};
await persistConnectorCredential(persistArgs);
deps.providerHealth?.clear(tenant.id, descriptor.id);
return {
kind: "connected",
Expand Down
Loading
Loading