diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index ee9cddf949c..de8f617223b 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -178,7 +178,10 @@ export const ClaudeDriver: ProviderDriver = { streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, initialSnapshot: (settings) => - makePendingClaudeProvider(settings.provider).pipe(Effect.map(stampIdentity)), + makePendingClaudeProvider(settings.provider).pipe( + Effect.map(stampIdentity), + Effect.provideService(Path.Path, path), + ), checkProvider, enrichSnapshot: ({ settings, snapshot, publishSnapshot }) => enrichProviderSnapshotWithVersionAdvisory(snapshot, maintenanceCapabilities, { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index ff0d1992454..460e1dfaecc 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -3,6 +3,7 @@ import { type ModelCapabilities, type ModelSelection, type ServerProviderModel, + type ServerProviderReauthentication, type ServerProviderSlashCommand, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -37,7 +38,7 @@ import { type ServerProviderDraft, } from "../providerSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts"; -import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; +import { makeClaudeEnvironment, resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts"; const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [], @@ -378,6 +379,53 @@ export function resolveClaudeApiModelId(modelSelection: ModelSelection): string } } +const CLAUDE_REAUTHENTICATION_ARGS = ["setup-token"] as const; + +/** + * Build the in-app re-authentication descriptor for a Claude provider + * instance. + * + * Runs `claude setup-token`, which performs the interactive OAuth login + * (prints a URL, then accepts the pasted authorization code) and stores a + * fresh long-lived token. Surfacing this to the client lets users recover + * from an expired Claude OAuth access token — e.g. a + * `401 OAuth access token has expired` turn failure — from within T3 Code's + * integrated terminal instead of dropping to an external shell. + * + * The configured `binaryPath` is preserved so custom Claude installs + * re-authenticate the same binary they run, and `CLAUDE_CONFIG_DIR` is + * propagated for instances with a custom home so `setup-token` refreshes the + * credentials of that exact instance rather than the default config dir + * (mirroring {@link makeClaudeEnvironment} used by every other Claude + * invocation). + * + * `command` is a plain space-join intended for the common case (`claude` on + * PATH, or a path without spaces) and to stay portable across the integrated + * terminal's shell — deliberately NOT shell-quoted, since no single quoting + * scheme is correct for bash/zsh (`~` expansion), `cmd.exe` (literal single + * quotes), and POSIX paths with spaces at once. Callers that need exact argv + * (e.g. spawning directly rather than pasting into a shell) should use + * `executable` + `args`. + */ +export const resolveClaudeReauthentication = Effect.fn("resolveClaudeReauthentication")(function* ( + claudeSettings: ClaudeSettings, +): Effect.fn.Return { + const executable = claudeSettings.binaryPath?.trim() || "claude"; + const args = [...CLAUDE_REAUTHENTICATION_ARGS]; + const command = [executable, ...args].join(" "); + const hasCustomHome = claudeSettings.homePath.trim().length > 0; + const env = hasCustomHome + ? { CLAUDE_CONFIG_DIR: yield* resolveClaudeHomePath(claudeSettings) } + : undefined; + return { + command, + executable, + args, + label: "Re-authenticate Claude", + ...(env ? { env } : {}), + } satisfies ServerProviderReauthentication; +}); + function toTitleCaseWords(value: string): string { const parts: Array = []; for (const part of value.split(/[\s_-]+/g)) { @@ -687,6 +735,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( > { const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); + const reauthentication = yield* resolveClaudeReauthentication(claudeSettings); const allModels = providerModelsFromSettings( BUILT_IN_MODELS, claudeSettings.customModels, @@ -725,6 +774,10 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + // Offer in-app re-auth only when the binary is actually present — a + // missing CLI can't be re-authenticated, but an installed one that + // failed its health check still might be (e.g. expired credentials). + ...(isCommandMissingCause(error) ? {} : { reauthentication }), probe: { installed: !isCommandMissingCause(error), version: null, @@ -743,6 +796,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: null, @@ -767,6 +821,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( enabled: claudeSettings.enabled, checkedAt, models: allModels, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -803,6 +858,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -824,6 +880,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( checkedAt, models, slashCommands: dedupedSlashCommands, + reauthentication, probe: { installed: true, version: parsedVersion, @@ -842,7 +899,7 @@ const nowIso = Effect.map(DateTime.now, DateTime.formatIso); export const makePendingClaudeProvider = ( claudeSettings: ClaudeSettings, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( @@ -867,11 +924,16 @@ export const makePendingClaudeProvider = ( }); } + // Expose re-authentication even on the pre-probe snapshot so a turn that + // fails with an auth error before the first status check completes can + // still offer the in-app recovery action. + const reauthentication = yield* resolveClaudeReauthentication(claudeSettings); return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: true, checkedAt, models, + reauthentication, probe: { installed: false, version: null, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 159d853121c..bb89cbbab4a 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1493,6 +1493,39 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.auth.status, "authenticated"); + assert.deepStrictEqual(status.reauthentication, { + command: "claude setup-token", + executable: "claude", + args: ["setup-token"], + label: "Re-authenticate Claude", + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "1.0.0\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("propagates CLAUDE_CONFIG_DIR re-auth env for a custom Claude home", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + { ...defaultClaudeSettings, homePath: "/tmp/t3-claude-home" }, + claudeCapabilities(), + ); + assert.strictEqual(status.reauthentication?.command, "claude setup-token"); + assert.deepStrictEqual(status.reauthentication?.env, { + CLAUDE_CONFIG_DIR: "/tmp/t3-claude-home", + }); }).pipe( Effect.provide( mockSpawnerLayer((args) => { @@ -1914,6 +1947,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te status.message, "Claude Agent CLI (`claude`) is not installed or not on PATH.", ); + // A missing binary cannot be re-authenticated, so no action is offered. + assert.strictEqual(status.reauthentication, undefined); }).pipe(Effect.provide(failingSpawnerLayer("spawn claude ENOENT"))), ); @@ -1928,6 +1963,9 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te assert.strictEqual(status.installed, true); assert.strictEqual(status.message, "Claude Agent CLI is installed but failed to run."); assert.ok(!(status.message ?? "").includes(secretStderr)); + // An installed CLI that failed its health check may still be + // recoverable (e.g. expired credentials), so re-auth is offered. + assert.strictEqual(status.reauthentication?.command, "claude setup-token"); }).pipe( Effect.provide( mockSpawnerLayer((args) => { diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index e741a7a2c1d..152179b6058 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -3,6 +3,7 @@ import type { ModelCapabilities, ServerProvider, ServerProviderAuth, + ServerProviderReauthentication, ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, @@ -215,6 +216,7 @@ export function buildServerProvider(input: { models: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; + reauthentication?: ServerProviderReauthentication; probe: ProviderProbeResult; }): ServerProviderDraft { const versionAdvisory = input.driver @@ -243,6 +245,7 @@ export function buildServerProvider(input: { models: input.models, slashCommands: [...(input.slashCommands ?? [])], skills: [...(input.skills ?? [])], + ...(input.reauthentication ? { reauthentication: input.reauthentication } : {}), ...(versionAdvisory ? { versionAdvisory } : {}), }; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c296c717066..f6c3e6a76d1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10,6 +10,7 @@ import { type ProviderApprovalDecision, ProviderInstanceId, type ServerProvider, + type ServerProviderReauthentication, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -266,6 +267,24 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT = const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; + +/** + * Heuristic for whether a thread/turn error stems from an expired or missing + * provider credential — the signals that make an in-app "Re-authenticate" + * action worth offering (e.g. Claude's + * `401 OAuth access token has expired. Re-authenticate to continue.`). + * + * Intentionally narrow: it matches credential-specific phrasing rather than + * bare tokens like `401`/`oauth`/`api key`, which also appear in generic tool + * or HTTP failures and would otherwise surface a misleading re-authenticate + * action. + */ +function isProviderAuthError(message: string | null | undefined): boolean { + if (!message) return false; + return /(re-?authenticate|re-?auth\b|not (?:logged in|authenticated)|unauthenticated|authentication (?:failed|error|required)|access token (?:has )?expired|expired .*token|invalid (?:api key|access token|credentials)|please (?:log ?in|sign ?in)|(?:log|sign) ?in again)/i.test( + message, + ); +} const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroupRef = useRef(null); @@ -2290,6 +2309,21 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + // The thread error banner's re-authenticate action must target the provider + // instance that actually ran the failing turn (the thread session's + // provider), not whatever the composer currently has selected — otherwise + // switching the picker after a failure could re-authenticate the wrong + // Claude instance. Falls back to the active provider before a session exists. + const sessionProviderInstanceId = activeThread?.session?.providerInstanceId ?? null; + const threadErrorProviderStatus = useMemo(() => { + if (sessionProviderInstanceId) { + return ( + providerStatuses.find((status) => status.instanceId === sessionProviderInstanceId) ?? + activeProviderStatus + ); + } + return activeProviderStatus; + }, [sessionProviderInstanceId, providerStatuses, activeProviderStatus]); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -2703,6 +2737,35 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const reauthenticateProvider = useCallback( + (reauthentication: ServerProviderReauthentication) => { + // Reuse the project-script launcher so re-authentication runs through + // the same proven "open/reuse a terminal, focus it, write the command" + // path. A fresh terminal keeps the interactive OAuth prompt (URL + + // pasted code) from colliding with an in-flight shell, and + // `rememberAsLastInvoked: false` keeps this synthetic command out of the + // per-project "last run script" state. + void runProjectScript( + { + id: "__t3-code-reauthenticate__", + name: reauthentication.label ?? "Re-authenticate", + command: reauthentication.command, + icon: "configure", + runOnWorktreeCreate: false, + }, + { + preferNewTerminal: true, + rememberAsLastInvoked: false, + // Carry the provider's isolation env (e.g. CLAUDE_CONFIG_DIR for a + // custom Claude home) so the login refreshes the correct instance's + // credentials rather than the default config dir. + ...(reauthentication.env ? { env: { ...reauthentication.env } } : {}), + }, + ); + }, + [runProjectScript], + ); + const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -5237,11 +5300,29 @@ function ChatViewContent(props: ChatViewProps) { {/* Error banner */} - - setThreadError(activeThread.id, null)} + + {(() => { + const reauth = threadErrorProviderStatus?.reauthentication; + if (!reauth || !isProviderAuthError(threadError)) { + return ( + setThreadError(activeThread.id, null)} + /> + ); + } + return ( + setThreadError(activeThread.id, null)} + onReauthenticate={() => reauthenticateProvider(reauth)} + {...(reauth.label ? { reauthenticateLabel: reauth.label } : {})} + /> + ); + })()} {/* Main content area with optional plan sidebar */}
{/* Chat column */} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx index c725b3e275a..c8f9c13df99 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.tsx @@ -1,14 +1,23 @@ -import { type ServerProvider } from "@t3tools/contracts"; +import { type ServerProvider, type ServerProviderReauthentication } from "@t3tools/contracts"; import { memo } from "react"; -import { InfoIcon } from "lucide-react"; +import { InfoIcon, KeyRoundIcon } from "lucide-react"; import { cn } from "~/lib/utils"; import { formatProviderDriverKindLabel } from "../../providerModels"; +import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export const ProviderStatusBanner = memo(function ProviderStatusBanner({ status, + onReauthenticate, }: { status: ServerProvider | null; + /** + * Invoked when the user clicks the in-app "Re-authenticate" action. Only + * offered when the provider is unauthenticated and advertised a + * `reauthentication` descriptor. Runs the login command inside the thread's + * integrated terminal. + */ + onReauthenticate?: (reauthentication: ServerProviderReauthentication) => void; }) { if (!status || status.status === "ready" || status.status === "disabled") { return null; @@ -16,11 +25,16 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({ const providerName = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); const isUnauthenticated = status.status === "error" && status.auth.status === "unauthenticated"; + const reauthentication = status.reauthentication ?? null; + const canReauthenticate = + isUnauthenticated && Boolean(reauthentication) && Boolean(onReauthenticate); const title = isUnauthenticated ? `${providerName} is unauthenticated` : `${providerName} provider status`; const message = isUnauthenticated - ? "Sign in via the CLI to authenticate again." + ? canReauthenticate + ? "Re-authenticate to keep using this provider." + : "Sign in via the CLI to authenticate again." : (status.message ?? (status.status === "error" ? `${providerName} provider is unavailable.` @@ -49,6 +63,17 @@ export const ProviderStatusBanner = memo(function ProviderStatusBanner({
+ {canReauthenticate && reauthentication ? ( + + ) : null} ); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index 51bfb62667d..8a768c83c8f 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -1,15 +1,25 @@ import { memo } from "react"; import { Alert, AlertAction, AlertDescription } from "../ui/alert"; import { Button } from "../ui/button"; -import { CircleAlertIcon, XIcon } from "lucide-react"; +import { CircleAlertIcon, KeyRoundIcon, XIcon } from "lucide-react"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, + onReauthenticate, + reauthenticateLabel, }: { error: string | null; onDismiss?: () => void; + /** + * When provided, renders a "Re-authenticate" action alongside the error. + * The caller decides when to offer it — typically when the error looks like + * an expired/failed provider credential and the active provider advertises + * an in-app re-authentication command. + */ + onReauthenticate?: () => void; + reauthenticateLabel?: string; }) { if (!error) return null; return ( @@ -24,11 +34,19 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ - {onDismiss && ( + {(onReauthenticate || onDismiss) && ( - + {onReauthenticate && ( + + )} + {onDismiss && ( + + )} )} diff --git a/docs/providers/claude.md b/docs/providers/claude.md index bbf72722cf1..4776c57a7b6 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -29,6 +29,25 @@ Claude HOME path: empty An empty `Claude HOME path` means T3 Code uses your normal home directory. +## My Claude Login Expired + +If Claude Code's OAuth token expires, a turn fails with an error such as: + +```text +Failed to authenticate. API Error: 401 OAuth access token has expired. Re-authenticate to continue. +``` + +You do not need to leave T3 Code. Click **Re-authenticate** on the error banner +(or on the provider's status banner when it reports "unauthenticated"). T3 Code +opens an integrated terminal and runs `claude setup-token` for that provider, +using its configured binary and Claude HOME. Follow the prompt — open the +printed URL, approve access, and paste the authorization code back into the +terminal. Once it finishes, retry your turn. + +The re-authenticate action runs against the same Claude provider you were using, +so multi-account and custom-home setups (below) re-authenticate the correct +account. + ## I Want Work And Personal Claude Accounts Use a different Claude home for each account. diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c906f86f4dc..af2581f4dc7 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -25,6 +25,60 @@ describe("ServerProvider", () => { expect(parsed.skills).toEqual([]); expect(parsed.versionAdvisory).toBeUndefined(); expect(parsed.updateState).toBeUndefined(); + expect(parsed.reauthentication).toBeUndefined(); + }); + + it("decodes an in-app re-authentication descriptor", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "2.1.169", + status: "error", + auth: { + status: "unauthenticated", + }, + reauthentication: { + command: "claude setup-token", + executable: "claude", + args: ["setup-token"], + label: "Re-authenticate Claude", + env: { CLAUDE_CONFIG_DIR: "/home/dev/.claude_work" }, + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.reauthentication?.command).toBe("claude setup-token"); + expect(parsed.reauthentication?.executable).toBe("claude"); + expect(parsed.reauthentication?.args).toEqual(["setup-token"]); + expect(parsed.reauthentication?.label).toBe("Re-authenticate Claude"); + expect(parsed.reauthentication?.env).toEqual({ + CLAUDE_CONFIG_DIR: "/home/dev/.claude_work", + }); + }); + + it("defaults re-authentication args when omitted", () => { + const parsed = decodeServerProvider({ + instanceId: "claudeAgent", + driver: "claudeAgent", + enabled: true, + installed: true, + version: "2.1.169", + status: "ready", + auth: { + status: "authenticated", + }, + reauthentication: { + command: "claude setup-token", + executable: "claude", + }, + checkedAt: "2026-04-10T00:00:00.000Z", + models: [], + }); + + expect(parsed.reauthentication?.args).toEqual([]); }); it("defaults one-click update support when decoding older advisory snapshots", () => { diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 316f09693ec..89ec9ed0ec0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -58,6 +58,33 @@ export const ServerProviderAuth = Schema.Struct({ }); export type ServerProviderAuth = typeof ServerProviderAuth.Type; +/** + * Describes how a provider can be (re-)authenticated from within T3 Code. + * + * When present, the client may offer an in-app "Re-authenticate" action that + * runs `command` — an interactive CLI login such as `claude setup-token` or + * `codex login` — inside an integrated terminal using the provider's resolved + * environment. This lets users recover from an expired credential (e.g. a + * Claude `401 OAuth access token has expired` turn failure) without dropping + * to an external shell. `executable`/`args` carry the same command already + * split for callers that spawn directly instead of through a shell. + */ +export const ServerProviderReauthentication = Schema.Struct({ + command: TrimmedNonEmptyString, + executable: TrimmedNonEmptyString, + args: Schema.Array(TrimmedNonEmptyString).pipe(Schema.withDecodingDefault(Effect.succeed([]))), + label: Schema.optional(TrimmedNonEmptyString), + /** + * Environment overrides the login command must run with so it targets the + * same account/config as the provider instance — e.g. `CLAUDE_CONFIG_DIR` + * for a Claude instance with a custom home. Only non-secret isolation + * variables belong here; the client layers them onto the terminal + * environment when launching the command. + */ + env: Schema.optional(Schema.Record(TrimmedNonEmptyString, Schema.String)), +}); +export type ServerProviderReauthentication = typeof ServerProviderReauthentication.Type; + export const ServerProviderModel = Schema.Struct({ slug: TrimmedNonEmptyString, name: TrimmedNonEmptyString, @@ -171,6 +198,10 @@ export const ServerProvider = Schema.Struct({ version: Schema.NullOr(TrimmedNonEmptyString), status: ServerProviderState, auth: ServerProviderAuth, + // Present when the provider exposes an in-app re-authentication command + // (see `ServerProviderReauthentication`). Optional/back-compat: legacy + // producers omit it and consumers simply hide the re-authenticate action. + reauthentication: Schema.optionalKey(ServerProviderReauthentication), checkedAt: IsoDateTime, message: Schema.optional(TrimmedNonEmptyString), // Optional for back-compat: every legacy producer omits this field and