fix: preserve provider and workflow behavior after migration
Use canonical Anthropic OAuth refresh, keep CLI-backed providers out of API-key auth rows, parse Grok's omitted zero usage, and carry board workflow context into task creation.
This commit is contained in:
7
.changeset/fresh-migration-provider-workflows.md
Normal file
7
.changeset/fresh-migration-provider-workflows.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Preserve OAuth, CLI authentication, and selected workflow behavior after PostgreSQL migration.
|
||||
category: fix
|
||||
dev: Refreshes Anthropic OAuth canonically, de-duplicates CLI auth rows, parses zero-use Grok billing, and forwards workflow context.
|
||||
@@ -452,6 +452,7 @@ export function AppModals({
|
||||
addToast={addToast}
|
||||
projectId={projectId}
|
||||
initialDescription={modalManager.newTaskInitialDescription ?? ""}
|
||||
initialWorkflowId={modalManager.newTaskInitialWorkflowId}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
/>
|
||||
|
||||
@@ -40,7 +40,7 @@ interface BoardProps {
|
||||
onOpenGroupModal?: (groupId: string) => void;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
|
||||
onNewTask: () => void;
|
||||
onNewTask: (workflowId?: string | null) => void;
|
||||
autoMerge: boolean;
|
||||
/** Project merge strategy passed to Board-owned card context menus. */
|
||||
mergeStrategy?: string;
|
||||
@@ -617,6 +617,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id;
|
||||
}, [selectedWorkflowColumns]);
|
||||
|
||||
const handleSelectedWorkflowNewTask = useCallback(() => {
|
||||
onNewTask(selectedWorkflow?.id);
|
||||
}, [onNewTask, selectedWorkflow?.id]);
|
||||
|
||||
const workflowContextMenuColumnsByWorkflowId = useMemo(() => {
|
||||
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
|
||||
for (const workflow of boardWorkflows?.workflows ?? []) {
|
||||
@@ -786,6 +790,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
return null;
|
||||
}, [boardWorkflows]);
|
||||
|
||||
const handleAggregateWorkflowNewTask = useCallback(() => {
|
||||
onNewTask(aggregateQuickCreateTarget?.workflowId);
|
||||
}, [aggregateQuickCreateTarget?.workflowId, onNewTask]);
|
||||
|
||||
const aggregateVisibleBoardColumns = useMemo(
|
||||
() => aggregateBoardColumns.filter((column) => column.flags.archived !== true),
|
||||
[aggregateBoardColumns],
|
||||
@@ -972,7 +980,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
mergeStrategy={mergeStrategy}
|
||||
// FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair.
|
||||
{...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
|
||||
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})}
|
||||
{...(isCreateColumn && aggregateQuickCreateTarget ? { workflowId: aggregateQuickCreateTarget.workflowId, workflowOptions, defaultWorkflowId: boardWorkflows?.defaultWorkflowId ?? null, onQuickCreate: handleAggregateWorkflowQuickCreate, onNewTask: handleAggregateWorkflowNewTask, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||
@@ -1055,7 +1063,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
|
||||
mergeStrategy={mergeStrategy}
|
||||
// FNXC:PlanApproval 2026-07-07-00:00: FN-7653 — the plan auto-approve shortcut belongs only to the intake/planning column, never to hold (Todo-like) columns; the built-in Coding workflow's Todo column carries the hold trait and was wrongly receiving this prop pair.
|
||||
{...((columnDef.flags.intake && !columnDef.flags.archived && !columnDef.flags.complete && !columnDef.flags.countsTowardWip && !columnDef.flags.mergeBlocker && !columnDef.flags.humanReview) ? { planAutoApproveEnabled, onTogglePlanAutoApprove } : {})}
|
||||
{...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask, onSubtaskBreakdown } : {})}
|
||||
{...(isCreateColumn ? { workflowOptions, defaultWorkflowId: selectedWorkflow.id, onQuickCreate: handleWorkflowQuickCreate, onNewTask: handleSelectedWorkflowNewTask, onSubtaskBreakdown } : {})}
|
||||
{...(columnDef.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
|
||||
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
|
||||
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
|
||||
|
||||
@@ -48,6 +48,7 @@ interface NewTaskModalProps {
|
||||
onCreateTask: (input: NewTaskCreateInput) => Promise<Task>;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
initialDescription?: string;
|
||||
initialWorkflowId?: string | null;
|
||||
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
|
||||
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
|
||||
}
|
||||
@@ -396,7 +397,7 @@ function NewTaskGitHubReferencePicker({ isOpen, projectId, disabled = false, onS
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask, addToast, initialDescription = "", initialWorkflowId, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { confirm } = useConfirm();
|
||||
const viewportMode = useViewportMode();
|
||||
@@ -634,9 +635,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
useEffect(() => {
|
||||
if (isOpen && !wasOpenRef.current) {
|
||||
setDescription(initialDescription);
|
||||
/*
|
||||
FNXC:TaskWorkflowSelection 2026-07-14-14:22:
|
||||
A New Task dialog opened from a workflow board lane must inherit that viewed workflow atomically; global/sidebar opens omit this value and continue inheriting the project default (Coding). This prevents a task created while viewing Coding (Ideas) from silently landing in plain Coding.
|
||||
*/
|
||||
setSelectedWorkflowId(initialWorkflowId);
|
||||
}
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [initialDescription, isOpen]);
|
||||
}, [initialDescription, initialWorkflowId, isOpen]);
|
||||
|
||||
// Load agents for agent picker
|
||||
const loadAgents = useCallback(() => {
|
||||
|
||||
@@ -111,6 +111,11 @@ vi.mock("../Column", () => ({
|
||||
quick-create-{column}
|
||||
</button>
|
||||
) : null}
|
||||
{onNewTask ? (
|
||||
<button type="button" data-testid={`mock-new-task-${column}`} onClick={() => (onNewTask as () => void)()}>
|
||||
new-task-{column}
|
||||
</button>
|
||||
) : null}
|
||||
{tasks.map((task) => (
|
||||
<article key={task.id} data-testid={`board-task-card-${task.id}`}>
|
||||
{task.title ?? task.description ?? task.id}
|
||||
@@ -1630,6 +1635,17 @@ describe("Board", () => {
|
||||
expect(JSON.parse(intakeColumn.getAttribute("data-workflow-options") || "[]")).toEqual(["builtin:coding", "wf-custom"]);
|
||||
});
|
||||
|
||||
it("opens the full task dialog with the selected workflow context", async () => {
|
||||
const onNewTask = vi.fn();
|
||||
enableFlag({ "FN-1": CUSTOM_WORKFLOW.id }, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ tasks: [mkTask({ id: "FN-1", column: "intake" })], onNewTask });
|
||||
|
||||
await selectWorkflow(CUSTOM_WORKFLOW.id);
|
||||
fireEvent.click(screen.getByTestId("mock-new-task-intake"));
|
||||
|
||||
expect(onNewTask).toHaveBeenCalledWith(CUSTOM_WORKFLOW.id);
|
||||
});
|
||||
|
||||
it("defaults All workflows quick-add to the default workflow and resolves selected workflow columns", async () => {
|
||||
const onQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new", workflowId: "wf-custom" });
|
||||
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
@@ -1649,6 +1665,17 @@ describe("Board", () => {
|
||||
expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" }));
|
||||
});
|
||||
|
||||
it("opens the All workflows task dialog with its resolved real workflow context", async () => {
|
||||
const onNewTask = vi.fn();
|
||||
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
|
||||
renderBoard({ onNewTask });
|
||||
|
||||
await selectWorkflow("__all_workflows__");
|
||||
fireEvent.click(screen.getByTestId("mock-new-task-triage"));
|
||||
|
||||
expect(onNewTask).toHaveBeenCalledWith(DEFAULT_WORKFLOW.id);
|
||||
});
|
||||
|
||||
it("restores all-workflows after remount and then persists a real workflow selection", async () => {
|
||||
const projectId = "project-board-all-workflows-remount";
|
||||
enableFlag(
|
||||
|
||||
@@ -1552,6 +1552,21 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("submits the workflow supplied by the contextual board opener", async () => {
|
||||
await mockWorkflows([{ id: "WF-IDEAS", name: "Coding (Ideas)" }]);
|
||||
const { props } = renderNewTaskModal({ initialWorkflowId: "WF-IDEAS" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("Coding (Ideas)");
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Stay in Ideas" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-IDEAS" }));
|
||||
});
|
||||
});
|
||||
|
||||
it("sends workflowId: null when 'No workflow' is chosen", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ModalManager {
|
||||
// State
|
||||
newTaskModalOpen: boolean;
|
||||
newTaskInitialDescription: string | null;
|
||||
newTaskInitialWorkflowId: string | null | undefined;
|
||||
isPlanningOpen: boolean;
|
||||
planningInitialPlan: string | null;
|
||||
planningResumeSessionId: string | undefined;
|
||||
@@ -88,7 +89,7 @@ export interface ModalManager {
|
||||
anyModalOpen: boolean;
|
||||
|
||||
// Handlers
|
||||
openNewTask: () => void;
|
||||
openNewTask: (workflowId?: string | null) => void;
|
||||
openNewTaskWithDescription: (description: string) => void;
|
||||
closeNewTask: () => void;
|
||||
|
||||
@@ -177,6 +178,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
|
||||
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
|
||||
const [newTaskInitialDescription, setNewTaskInitialDescription] = useState<string | null>(null);
|
||||
const [newTaskInitialWorkflowId, setNewTaskInitialWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
const [isPlanningOpen, setIsPlanningOpen] = useState(false);
|
||||
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
|
||||
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
|
||||
@@ -250,17 +252,20 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
modelOnboardingOpen,
|
||||
);
|
||||
|
||||
const openNewTask = useCallback(() => {
|
||||
const openNewTask = useCallback((workflowId?: string | null) => {
|
||||
setNewTaskInitialDescription(null);
|
||||
setNewTaskInitialWorkflowId(workflowId);
|
||||
setNewTaskModalOpen(true);
|
||||
}, []);
|
||||
const openNewTaskWithDescription = useCallback((description: string) => {
|
||||
setNewTaskInitialDescription(description);
|
||||
setNewTaskInitialWorkflowId(undefined);
|
||||
setNewTaskModalOpen(true);
|
||||
}, []);
|
||||
const closeNewTask = useCallback(() => {
|
||||
setNewTaskModalOpen(false);
|
||||
setNewTaskInitialDescription(null);
|
||||
setNewTaskInitialWorkflowId(undefined);
|
||||
}, []);
|
||||
|
||||
const openPlanning = useCallback(() => {
|
||||
@@ -491,6 +496,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
|
||||
return {
|
||||
newTaskModalOpen,
|
||||
newTaskInitialDescription,
|
||||
newTaskInitialWorkflowId,
|
||||
isPlanningOpen,
|
||||
planningInitialPlan,
|
||||
planningResumeSessionId,
|
||||
|
||||
@@ -1641,6 +1641,22 @@ describe("GET /auth/status", () => {
|
||||
expect(providerIds).not.toContain("anthropic");
|
||||
expect(providerIds.filter((id: string) => id === "anthropic-subscription")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not duplicate CLI-backed providers as unauthenticated API-key rows", async () => {
|
||||
(authStorage.getApiKeyProviders as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{ id: "grok-cli", name: "Grok Cli" },
|
||||
{ id: "omp-cli", name: "OMP Cli" },
|
||||
{ id: "a-brand-new-api-provider", name: "Brand New API Provider" },
|
||||
]);
|
||||
|
||||
const res = await GET(app, "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers.filter((provider: any) => provider.id === "grok-cli")).toHaveLength(1);
|
||||
expect(res.body.providers.find((provider: any) => provider.id === "grok-cli")).toMatchObject({ type: "cli" });
|
||||
expect(res.body.providers.filter((provider: any) => provider.id === "omp-cli")).toHaveLength(1);
|
||||
expect(res.body.providers.find((provider: any) => provider.id === "a-brand-new-api-provider")).toMatchObject({ type: "api_key" });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -3653,6 +3653,40 @@ describe("usage", () => {
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("treats an omitted protobuf zero percentage as 0% for a valid weekly CLI billing period", async () => {
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
if (String(filePath).includes(".grok/auth.json")) return GROK_CLI_AUTH_JSON;
|
||||
return Promise.reject(new Error("File not found"));
|
||||
});
|
||||
mockExecFileSync.mockImplementation(() => {
|
||||
throw new Error("Keychain item not found");
|
||||
});
|
||||
const periodEnd = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
mockGrokBillingResponse(200, {
|
||||
config: {
|
||||
currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start: new Date().toISOString(), end: periodEnd },
|
||||
billingPeriodEnd: periodEnd,
|
||||
onDemandCap: { val: 0 },
|
||||
onDemandUsed: { val: 0 },
|
||||
prepaidBalance: { val: 0 },
|
||||
isUnifiedBillingUser: true,
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await fetchAllProviderUsage();
|
||||
const grok = providers.find((provider) => provider.name === "Grok")!;
|
||||
|
||||
expect(grok.status).toBe("ok");
|
||||
expect(grok.windows).toHaveLength(1);
|
||||
expect(grok.windows[0]).toMatchObject({
|
||||
label: "Weekly (credits)",
|
||||
percentUsed: 0,
|
||||
percentLeft: 100,
|
||||
});
|
||||
expect(grok.windows[0].resetText).toContain("resets in");
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the xAI API-key validity card when CLI billing fails", async () => {
|
||||
vi.stubEnv("GROK_API_KEY", "env-grok-key");
|
||||
mockReadFile.mockImplementation(async (filePath: string) => {
|
||||
|
||||
@@ -32,6 +32,20 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, getScopedStore, rethrowAsApiError } = ctx;
|
||||
const authStorage = options?.authStorage;
|
||||
|
||||
/*
|
||||
FNXC:ProviderAuth 2026-07-14-14:22:
|
||||
CLI-backed providers own their credentials and have dedicated status rows below. Runtime model registration can also expose those ids through getApiKeyProviders(); exclude them from the generic API-key union so Grok cannot render twice as both "missing API key" and ready via its authenticated CLI.
|
||||
*/
|
||||
const syntheticCliProviderIds = new Set([
|
||||
"claude-cli",
|
||||
"pi-claude-cli",
|
||||
"droid-cli",
|
||||
"cursor-cli",
|
||||
"grok-cli",
|
||||
"omp-cli",
|
||||
"llama-cpp",
|
||||
]);
|
||||
|
||||
// Use injected AuthStorage or fail gracefully if not provided.
|
||||
// When running via the CLI/engine, AuthStorage is passed in via ServerOptions.
|
||||
function getAuthStorage(): AuthStorageLike {
|
||||
@@ -618,7 +632,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
// storage.getApiKeyProviders may be absent/narrowed, but the catalog
|
||||
// entries must still surface as present-but-unauthenticated.
|
||||
{
|
||||
const runtimeApiKeyProviders = storage.getApiKeyProviders ? storage.getApiKeyProviders() : [];
|
||||
const runtimeApiKeyProviders = storage.getApiKeyProviders
|
||||
? storage.getApiKeyProviders().filter((provider) => !syntheticCliProviderIds.has(provider.id))
|
||||
: [];
|
||||
const apiKeyProviders = unionProviderCatalog(STATIC_API_KEY_PROVIDER_CATALOG, runtimeApiKeyProviders);
|
||||
for (const p of apiKeyProviders) {
|
||||
let keyHint: string | undefined;
|
||||
|
||||
@@ -1678,11 +1678,20 @@ async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Pr
|
||||
const config = data?.config;
|
||||
if (!config || typeof config !== "object") return false;
|
||||
|
||||
const pctUsed = config.creditUsagePercent;
|
||||
if (typeof pctUsed !== "number" || !Number.isFinite(pctUsed)) return false;
|
||||
|
||||
const parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end);
|
||||
const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY";
|
||||
/*
|
||||
FNXC:UsageProviders 2026-07-14-14:47:
|
||||
Grok's billing endpoint uses protobuf-style JSON and omits the numeric `creditUsagePercent` field when its value is zero. The Grok Build CLI renders that valid reduced weekly config as “Weekly limit: 0%”; Fusion must mirror the CLI instead of treating an authenticated 200 response as expired auth. Only infer zero when the response still proves a weekly billing period and reset boundary, so malformed payloads continue to fail closed.
|
||||
*/
|
||||
const rawPercentUsed = config.creditUsagePercent;
|
||||
const pctUsed = typeof rawPercentUsed === "number" && Number.isFinite(rawPercentUsed)
|
||||
? rawPercentUsed
|
||||
: isWeekly && parsedReset
|
||||
? 0
|
||||
: undefined;
|
||||
if (pctUsed === undefined) return false;
|
||||
|
||||
usage.windows.push({
|
||||
label: isWeekly ? "Weekly (credits)" : "Credits",
|
||||
percentUsed: Math.min(100, Math.max(0, pctUsed)),
|
||||
|
||||
@@ -324,7 +324,7 @@ describe("createFusionAuthStorage", () => {
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
access_token: "refreshed-legacy-access-token",
|
||||
refresh_token: "rotated-legacy-refresh-token",
|
||||
expires_in: 3600,
|
||||
@@ -400,7 +400,7 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
access_token: "refreshed-subscription-access-token",
|
||||
refresh_token: "rotated-subscription-refresh-token",
|
||||
expires_in: 3600,
|
||||
@@ -469,7 +469,7 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
access_token: "proactively-refreshed-access-token",
|
||||
refresh_token: "rotated-refresh-token",
|
||||
expires_in: 3600,
|
||||
@@ -516,7 +516,11 @@ describe("createFusionAuthStorage", () => {
|
||||
expires: Date.now() - 60_000,
|
||||
},
|
||||
});
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({ ok: false } as Response) as typeof fetch;
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => JSON.stringify({ error: "invalid_grant" }),
|
||||
} as Response) as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
|
||||
@@ -732,7 +736,7 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
access_token: "refreshed-claude-access-token",
|
||||
refresh_token: "rotated-claude-refresh-token",
|
||||
expires_in: 3600,
|
||||
@@ -788,7 +792,7 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ expires_in: 3600 }),
|
||||
text: async () => JSON.stringify({ expires_in: 3600 }),
|
||||
} as Response) as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
@@ -813,7 +817,11 @@ describe("createFusionAuthStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false } as Response);
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: async () => JSON.stringify({ error: "invalid_grant" }),
|
||||
} as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
|
||||
const authStorage = createFusionAuthStorage();
|
||||
@@ -843,8 +851,9 @@ describe("createFusionAuthStorage", () => {
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
text: async () => JSON.stringify({
|
||||
access_token: "refreshed-claude-access-token",
|
||||
refresh_token: "rotated-claude-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
} as Response);
|
||||
@@ -879,11 +888,11 @@ describe("createFusionAuthStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
let resolveJson: ((value: unknown) => void) | undefined;
|
||||
let resolveText: ((value: string) => void) | undefined;
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => new Promise((resolve) => {
|
||||
resolveJson = resolve;
|
||||
text: () => new Promise<string>((resolve) => {
|
||||
resolveText = resolve;
|
||||
}),
|
||||
} as Response);
|
||||
globalThis.fetch = fetchMock as typeof fetch;
|
||||
@@ -899,11 +908,11 @@ describe("createFusionAuthStorage", () => {
|
||||
expires: Date.now() + 3_600_000,
|
||||
});
|
||||
|
||||
resolveJson?.({
|
||||
resolveText?.(JSON.stringify({
|
||||
access_token: "stale-refresh-access-token",
|
||||
refresh_token: "stale-refresh-refresh-token",
|
||||
expires_in: 3600,
|
||||
});
|
||||
}));
|
||||
|
||||
await expect(pendingRefresh).resolves.toBe("fresh-login-access-token");
|
||||
expect(authStorage.get("anthropic")).toEqual({
|
||||
|
||||
@@ -46,36 +46,8 @@ apply so a single stuck token doesn't get hammered).
|
||||
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
|
||||
const ANTHROPIC_PROVIDER_ID = "anthropic";
|
||||
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
|
||||
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
|
||||
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-05-18:52:
|
||||
Anthropic subscription login (delegated to pi-ai) grants the full Claude Code scope set — `user:inference` is what authorizes model calls. Earlier this constant was `["user:profile"]`, which was WRONG twice over: (1) it under-describes the token pi-ai actually obtains, and (2) it was fed into the refresh request's `scope` param, which under RFC 6749 §6 NARROWS the refreshed access token to profile-only and strips `user:inference`. The symptom: the account reads "logged in via OAuth" (token present + unexpired) yet every model call 403s with "OAuth token does not meet scope requirement any_of(user:inference, ...)". The default must mirror pi-ai's granted scopes so any fallback describes a usable token, and the refresh path (below) must NOT send it as a narrowing scope.
|
||||
*/
|
||||
const ANTHROPIC_DEFAULT_SCOPES = [
|
||||
"org:create_api_key",
|
||||
"user:profile",
|
||||
"user:inference",
|
||||
"user:sessions:claude_code",
|
||||
"user:mcp_servers",
|
||||
"user:file_upload",
|
||||
];
|
||||
const OAUTH_REFRESH_TIMEOUT_MS = 10_000;
|
||||
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
type OAuthTokenResponse = {
|
||||
access_token?: unknown;
|
||||
accessToken?: unknown;
|
||||
refresh_token?: unknown;
|
||||
refreshToken?: unknown;
|
||||
expires_in?: unknown;
|
||||
expiresIn?: unknown;
|
||||
expires_at?: unknown;
|
||||
expiresAt?: unknown;
|
||||
scope?: unknown;
|
||||
scopes?: unknown;
|
||||
};
|
||||
|
||||
export function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
}
|
||||
@@ -179,116 +151,27 @@ function isSameOAuthCredentialIdentity(
|
||||
&& left.expires === right.expires;
|
||||
}
|
||||
|
||||
function getOAuthScopes(credential: StoredCredential): string[] {
|
||||
const scopes = Array.isArray(credential.scopes)
|
||||
? credential.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0)
|
||||
: [];
|
||||
return scopes.length > 0 ? scopes : ANTHROPIC_DEFAULT_SCOPES;
|
||||
}
|
||||
|
||||
function parseExpiryMs(data: OAuthTokenResponse, now: number): number {
|
||||
const expiresAt = data.expires_at ?? data.expiresAt;
|
||||
if (typeof expiresAt === "number" && Number.isFinite(expiresAt)) {
|
||||
return expiresAt;
|
||||
}
|
||||
if (typeof expiresAt === "string") {
|
||||
const parsed = Date.parse(expiresAt);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
const expiresIn = data.expires_in ?? data.expiresIn;
|
||||
if (typeof expiresIn === "number" && Number.isFinite(expiresIn) && expiresIn > 0) {
|
||||
return now + expiresIn * 1000;
|
||||
}
|
||||
|
||||
return now + 3_600_000;
|
||||
}
|
||||
|
||||
function parseScopes(data: OAuthTokenResponse, fallback: string[]): string[] {
|
||||
if (Array.isArray(data.scopes)) {
|
||||
const scopes = data.scopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0);
|
||||
if (scopes.length > 0) {
|
||||
return scopes;
|
||||
}
|
||||
}
|
||||
if (typeof data.scope === "string") {
|
||||
const scopes = data.scope.split(/\s+/).filter(Boolean);
|
||||
if (scopes.length > 0) {
|
||||
return scopes;
|
||||
}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise<StoredCredential | undefined> {
|
||||
const refresh = credential.refresh;
|
||||
if (!refresh) {
|
||||
if (credential.type !== "oauth" || !credential.refresh) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scopes = getOAuthScopes(credential);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OAUTH_REFRESH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-06-13-22:46:
|
||||
Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires.
|
||||
Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths.
|
||||
FNXC:ClaudeOAuth 2026-07-14-14:25:
|
||||
Refresh through pi-ai's registered Anthropic provider—the same implementation that performs login and owns the endpoint, client id, expiry buffer, and response contract. Fusion's duplicated HTTP implementation drifted, so expired subscription OAuth degraded into a misleading missing-API-key failure after restart or PostgreSQL migration. Preserve Fusion's recorded scopes because the provider refresh result intentionally contains only runtime token fields.
|
||||
*/
|
||||
/*
|
||||
FNXC:ClaudeOAuth 2026-07-05-18:52:
|
||||
Do NOT send `scope` on refresh. RFC 6749 §6: a refresh request that includes `scope` re-issues the access token with EXACTLY that scope (never broader), so sending our stored/derived scope list can only strip capabilities — and did: it narrowed refreshed tokens to `user:profile` and broke inference. Omitting `scope` makes Anthropic preserve the originally-granted scopes (this is what pi-ai's own `refreshAnthropicToken` does). `scopes` is still resolved above and used only as the parseScopes fallback for the persisted credential record.
|
||||
*/
|
||||
const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"user-agent": "claude-code-fusion-dashboard",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refresh,
|
||||
client_id: ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const data = await response.json() as OAuthTokenResponse;
|
||||
const access = typeof data.access_token === "string"
|
||||
? data.access_token
|
||||
: typeof data.accessToken === "string"
|
||||
? data.accessToken
|
||||
: undefined;
|
||||
if (!access) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const nextRefresh = typeof data.refresh_token === "string"
|
||||
? data.refresh_token
|
||||
: typeof data.refreshToken === "string"
|
||||
? data.refreshToken
|
||||
: refresh;
|
||||
const provider = getOAuthProvider(ANTHROPIC_PROVIDER_ID);
|
||||
if (!provider?.refreshToken) return undefined;
|
||||
const refreshed = await provider.refreshToken(credential as OAuthCredentials);
|
||||
|
||||
return {
|
||||
...credential,
|
||||
type: "oauth",
|
||||
access,
|
||||
refresh: nextRefresh,
|
||||
expires: parseExpiryMs(data, now),
|
||||
scopes: parseScopes(data, scopes),
|
||||
...refreshed,
|
||||
...(credential.scopes ? { scopes: credential.scopes } : {}),
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user