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
2 changes: 1 addition & 1 deletion apps/hub/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"@corbits/error-sink": "workspace:*",
"@corbits/mailbox": "github:corbitsdev/corbits-mailbox#65590a85fa143251b3ac20ba2eca92fc35e70e51",
"@corbits/memory": "github:corbitsdev/corbits-memory#e74da20f148a302dff5400915fe504ee2395e913",
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#e97d563b823506d72f83dead478dd9fa9dccfd4c",
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core#f186ed0e14e8fc3bacb32efa1caabb02d9d5cafd",
"@corbits/url-path": "workspace:*",
"@corbits/webhooks": "github:corbitsdev/webhooks#3f4f83147fd826d3aec5bcac7ef4b0ae7f75c00d",
"@corbits/workflows": "workspace:*",
Expand Down
89 changes: 71 additions & 18 deletions apps/hub/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
createReconciliationScheduler,
DEFAULT_SIDECAR_ALLOCATION_CONCURRENCY,
pushCredentialReconcile,
pushSourceUpdates,
WORKSPACE_BUILTINS_REGISTRY,
type SidecarLookups,
type SidecarProvisioner,
Expand All @@ -57,9 +58,23 @@ import {
mountMailbox,
} from "@corbits/mailbox";
import { createMemory, loadMemoryConfig } from "@corbits/memory";
import { mountOAuthLogin } from "@corbits/oauth-core/hub";
import { CODEX_PROVIDER, codexOAuthConfig, exchangeCodexCode } from "@corbits/codex-provider";
import { XAI_PROVIDER, xaiOAuthConfig, exchangeXaiCode } from "@corbits/xai-provider";
import {
createOAuthTokenRefresher,
mountOAuthLogin,
type OAuthLoginProviders,
} from "@corbits/oauth-core/hub";
import {
CODEX_PROVIDER,
codexOAuthConfig,
exchangeCodexCode,
refreshCodexTokens,
} from "@corbits/codex-provider";
import {
XAI_PROVIDER,
xaiOAuthConfig,
exchangeXaiCode,
refreshXaiTokens,
} from "@corbits/xai-provider";
import { createCronTicker, createRunTriggerCronDeliver, mountCron } from "@corbits/cron";
import {
createHubMailboxAuthorizeSender,
Expand Down Expand Up @@ -570,6 +585,7 @@ export async function createHubServer({
};

let cronTicker: { start(): void; stop(): void } | undefined;
let oauthTokenRefresher: { start(): void; stop(): void } | undefined;
{
const cronApp = new Hono<TenantEnv>();
mountCron(cronApp, {
Expand Down Expand Up @@ -644,30 +660,67 @@ export async function createHubServer({
grantStore,
conditionRegistry: grantConditionRegistry,
});
const oauthProviders: OAuthLoginProviders = {
[CODEX_PROVIDER]: {
oauthConfig: codexOAuthConfig,
exchange: (code: string, verifier: string, now: number) =>
exchangeCodexCode(code, verifier, now),
// The account id the refresher carries forward lives on the
// credential, so the prior tokens need only supply the secret.
refresh: (refreshSecret: string, now: number) =>
refreshCodexTokens(refreshSecret, now, { access: "", refresh: refreshSecret }),
// The Codex backend rejects inference without this header value.
metadata: (tokens) =>
"accountId" in tokens && typeof tokens.accountId === "string"
? { accountId: tokens.accountId }
: {},
},
[XAI_PROVIDER]: {
oauthConfig: xaiOAuthConfig,
exchange: (code: string, verifier: string, now: number) =>
exchangeXaiCode(code, verifier, now),
refresh: (refreshSecret: string, now: number) => refreshXaiTokens(refreshSecret, now),
},
};
mountOAuthLogin(oauthLoginApi, {
db,
cipher: credentialCipher,
requireGrant: requireGrant("credential:*", "create"),
providers: {
[CODEX_PROVIDER]: {
oauthConfig: codexOAuthConfig,
exchange: (code, verifier, now) => exchangeCodexCode(code, verifier, now),
// The Codex backend rejects inference without this header value.
metadata: (tokens) =>
"accountId" in tokens && typeof tokens.accountId === "string"
? { accountId: tokens.accountId }
: {},
},
[XAI_PROVIDER]: {
oauthConfig: xaiOAuthConfig,
exchange: (code, verifier, now) => exchangeXaiCode(code, verifier, now),
},
},
providers: oauthProviders,
onError: (error, { provider }) => {
reportError(error, { operation: "hub.oauth-login", extra: { provider } });
},
});
app.route(TENANT_PREFIX, oauthLoginApi);

// Stock Interchange has no serving-time refresh hook, so a subscription
// token that lapses between inference calls would simply fail the next
// one; this renews it a little before expiry instead.
oauthTokenRefresher = createOAuthTokenRefresher({
db,
cipher: credentialCipher,
providers: oauthProviders,
intervalMs: 60_000,
onRefreshed: ({ tenantId, credentialId }) => {
// The same push the stock credentials route fires after a secret
// rotation, so running sidecars get the new material.
void pushSourceUpdates(db, sidecarRouter, tenantId, credentialCipher).catch(
(error: unknown) => {
reportError(error, {
operation: "hub.oauth-refresh.push",
extra: { tenantId, credentialId },
});
},
);
},
onError: (error, { provider, credentialId }) => {
reportError(error, {
operation: "hub.oauth-refresh",
extra: { provider: provider ?? "", credentialId: credentialId ?? "" },
});
},
});
oauthTokenRefresher.start();
}

await installWebhooks({
Expand Down
73 changes: 72 additions & 1 deletion apps/web/src/settings/credentials-section.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// The stock credential routes are the only way a key gets stored in this
// repo — no connector registry, no OAuth flow, no per-provider card.
// repo — no connector registry, no per-provider card. A credential minted
// by signing in is renewed by signing in again, through the same hub-hosted
// login the onboarding step uses.

import {
Button,
Expand All @@ -26,6 +28,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";

import { QueryView, toAPIQuery } from "@/lib/api-query";
import { readProviderLogin, startProviderLogin } from "@/settings/inference";
import { tenantKeys } from "@/query-client";
import {
createCredential,
Expand All @@ -40,6 +43,15 @@ import {
} from "./credentials-api";
import { SETTINGS_STRINGS } from "./strings";

/** The registered OAuth provider a credential was minted by, as
* `@corbits/oauth-core` records it; absent on a pasted key. */
function oauthProviderOf(credential: Credential): string | null {
const metadata: unknown = credential.metadata;
if (typeof metadata !== "object" || metadata === null) return null;
const value = (metadata as Record<string, unknown>).oauthProvider;
return typeof value === "string" && value.length > 0 ? value : null;
}

type CredentialsData = {
readonly credentials: readonly Credential[];
readonly providers: readonly Provider[];
Expand All @@ -49,6 +61,7 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [loginId, setLoginId] = useState<string | null>(null);

// Credentials and their providers load together: creating one needs the
// provider row for the typed name, so a split read would race the form.
Expand Down Expand Up @@ -93,6 +106,41 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
},
});

// Re-signing in files the tokens under the same credential name, so the
// hub replaces the material in place and every offering keeps pointing at it.
const signIn = useMutation({
mutationFn: (credential: Credential) => {
const provider = oauthProviderOf(credential);
if (tenantId === null || provider === null) {
throw new Error("that credential was not created by signing in");
}
return startProviderLogin(tenantId, {
provider,
providerId: credential.providerId,
credentialName: credential.name,
});
},
onSuccess: (started) => {
setLoginId(started.loginId);
window.open(started.authorizeUrl, "_blank", "noopener,noreferrer");
},
});

const login = useQuery({
queryKey: tenantKeys.credentials(tenantId ?? "none").concat("oauth-login", loginId ?? ""),
enabled: tenantId !== null && loginId !== null,
queryFn: async () => {
if (tenantId === null || loginId === null) throw new Error("no sign-in in flight");
const state = await readProviderLogin(tenantId, loginId);
if (state.status !== "pending") {
setLoginId(null);
reload();
}
return state;
},
refetchInterval: (query) => (query.state.data?.status === "pending" ? 2000 : false),
});

const update = useMutation({
mutationFn: (input: {
readonly credentialId: string;
Expand Down Expand Up @@ -150,10 +198,17 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n
{SETTINGS_STRINGS.credentialsDeleteError}
</p>
)}
{signIn.error === null || signIn.error === undefined ? null : (
<p className="settings-inline-error" role="alert">
{SETTINGS_STRINGS.credentialsSignInAgainError}
</p>
)}
<CredentialsTable
credentials={credentials}
signingIn={signIn.isPending || login.data?.status === "pending"}
onEdit={setEditing}
onDelete={(credential) => del.mutate(credential)}
onSignIn={(credential) => signIn.mutate(credential)}
/>
<CreateCredentialDialog
open={createOpen}
Expand All @@ -179,12 +234,16 @@ export function CredentialsSection({ tenantId }: { readonly tenantId: string | n

function CredentialsTable({
credentials,
signingIn,
onEdit,
onDelete,
onSignIn,
}: {
readonly credentials: readonly Credential[];
readonly signingIn: boolean;
readonly onEdit: (credential: Credential) => void;
readonly onDelete: (credential: Credential) => void;
readonly onSignIn: (credential: Credential) => void;
}) {
if (credentials.length === 0) {
return (
Expand All @@ -211,6 +270,18 @@ function CredentialsTable({
<TableCell>{credential.type}</TableCell>
<TableCell>{credential.status}</TableCell>
<TableCell className="settings-actions-cell">
{credential.type === "oauth_token" && oauthProviderOf(credential) !== null ? (
<Button
variant="outline"
size="sm"
disabled={signingIn}
onClick={() => onSignIn(credential)}
>
{signingIn
? SETTINGS_STRINGS.credentialsSignInAgainPending
: SETTINGS_STRINGS.credentialsSignInAgainAction}
</Button>
) : null}
<Button variant="outline" size="sm" onClick={() => onEdit(credential)}>
{SETTINGS_STRINGS.credentialsEditAction}
</Button>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/settings/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ export const SETTINGS_STRINGS = {
credentialsDeleteConfirm: "Delete this credential for good?",
credentialsDeleteError: "Couldn't delete that credential — try again.",
credentialsEditAction: "Edit",
credentialsSignInAgainAction: "Sign in again",
credentialsSignInAgainPending: "Waiting for sign-in…",
credentialsSignInAgainError: "Couldn't start that sign-in — try again.",
credentialsEditDialogTitle: "Edit credential",
credentialsEditDialogDescription:
"Base URL and model apply to a local, Ollama-style credential; leave them blank otherwise.",
Expand Down
Loading
Loading