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:
gsxdsm
2026-07-14 15:07:26 -07:00
parent 678265a526
commit 79d4299be2
13 changed files with 186 additions and 149 deletions

View 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.

View File

@@ -452,6 +452,7 @@ export function AppModals({
addToast={addToast} addToast={addToast}
projectId={projectId} projectId={projectId}
initialDescription={modalManager.newTaskInitialDescription ?? ""} initialDescription={modalManager.newTaskInitialDescription ?? ""}
initialWorkflowId={modalManager.newTaskInitialWorkflowId}
onPlanningMode={onPlanningMode} onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown} onSubtaskBreakdown={onSubtaskBreakdown}
/> />

View File

@@ -40,7 +40,7 @@ interface BoardProps {
onOpenGroupModal?: (groupId: string) => void; onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>; onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask: () => void; onNewTask: (workflowId?: string | null) => void;
autoMerge: boolean; autoMerge: boolean;
/** Project merge strategy passed to Board-owned card context menus. */ /** Project merge strategy passed to Board-owned card context menus. */
mergeStrategy?: string; mergeStrategy?: string;
@@ -617,6 +617,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id; ?? selectedWorkflowColumns.find((column) => !column.flags.archived)?.id;
}, [selectedWorkflowColumns]); }, [selectedWorkflowColumns]);
const handleSelectedWorkflowNewTask = useCallback(() => {
onNewTask(selectedWorkflow?.id);
}, [onNewTask, selectedWorkflow?.id]);
const workflowContextMenuColumnsByWorkflowId = useMemo(() => { const workflowContextMenuColumnsByWorkflowId = useMemo(() => {
const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>(); const map = new Map<string, readonly TaskContextMenuColumnMetadata[]>();
for (const workflow of boardWorkflows?.workflows ?? []) { for (const workflow of boardWorkflows?.workflows ?? []) {
@@ -786,6 +790,10 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
return null; return null;
}, [boardWorkflows]); }, [boardWorkflows]);
const handleAggregateWorkflowNewTask = useCallback(() => {
onNewTask(aggregateQuickCreateTarget?.workflowId);
}, [aggregateQuickCreateTarget?.workflowId, onNewTask]);
const aggregateVisibleBoardColumns = useMemo( const aggregateVisibleBoardColumns = useMemo(
() => aggregateBoardColumns.filter((column) => column.flags.archived !== true), () => aggregateBoardColumns.filter((column) => column.flags.archived !== true),
[aggregateBoardColumns], [aggregateBoardColumns],
@@ -972,7 +980,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
mergeStrategy={mergeStrategy} 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. // 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 } : {})} {...((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.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
{...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} {...(isDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}
@@ -1055,7 +1063,7 @@ export function Board({ tasks, projectId, maxConcurrent, showWorktreeGrouping, o
mergeStrategy={mergeStrategy} 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. // 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 } : {})} {...((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.flags.mergeBlocker || columnDef.flags.humanReview ? { onToggleAutoMerge: handleToggleAutoMerge } : {})}
{...(columnDef.id === "done" ? { onArchiveAllDone } : {})} {...(columnDef.id === "done" ? { onArchiveAllDone } : {})}
{...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})} {...(isWorkflowDoneLikeColumn ? { doneSortMode, onDoneSortModeChange: setDoneSortMode } : {})}

View File

@@ -48,6 +48,7 @@ interface NewTaskModalProps {
onCreateTask: (input: NewTaskCreateInput) => Promise<Task>; onCreateTask: (input: NewTaskCreateInput) => Promise<Task>;
addToast: (message: string, type?: ToastType) => void; addToast: (message: string, type?: ToastType) => void;
initialDescription?: string; initialDescription?: string;
initialWorkflowId?: string | null;
onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void; onPlanningMode?: (initialPlan: string, workflowId?: string | null) => void;
onSubtaskBreakdown?: (description: 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 { t } = useTranslation("app");
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const viewportMode = useViewportMode(); const viewportMode = useViewportMode();
@@ -634,9 +635,14 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
useEffect(() => { useEffect(() => {
if (isOpen && !wasOpenRef.current) { if (isOpen && !wasOpenRef.current) {
setDescription(initialDescription); 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; wasOpenRef.current = isOpen;
}, [initialDescription, isOpen]); }, [initialDescription, initialWorkflowId, isOpen]);
// Load agents for agent picker // Load agents for agent picker
const loadAgents = useCallback(() => { const loadAgents = useCallback(() => {

View File

@@ -111,6 +111,11 @@ vi.mock("../Column", () => ({
quick-create-{column} quick-create-{column}
</button> </button>
) : null} ) : null}
{onNewTask ? (
<button type="button" data-testid={`mock-new-task-${column}`} onClick={() => (onNewTask as () => void)()}>
new-task-{column}
</button>
) : null}
{tasks.map((task) => ( {tasks.map((task) => (
<article key={task.id} data-testid={`board-task-card-${task.id}`}> <article key={task.id} data-testid={`board-task-card-${task.id}`}>
{task.title ?? task.description ?? 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"]); 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 () => { 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" }); const onQuickCreate = vi.fn().mockResolvedValue({ id: "FN-new", workflowId: "wf-custom" });
enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]); enableFlag({}, [DEFAULT_WORKFLOW, CUSTOM_WORKFLOW]);
@@ -1649,6 +1665,17 @@ describe("Board", () => {
expect(onQuickCreate).not.toHaveBeenCalledWith(expect.objectContaining({ workflowId: "__all_workflows__" })); 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 () => { it("restores all-workflows after remount and then persists a real workflow selection", async () => {
const projectId = "project-board-all-workflows-remount"; const projectId = "project-board-all-workflows-remount";
enableFlag( enableFlag(

View File

@@ -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 () => { it("sends workflowId: null when 'No workflow' is chosen", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]); await mockWorkflows([{ id: "WF-1", name: "QA" }]);
const { props } = renderNewTaskModal(); const { props } = renderNewTaskModal();

View File

@@ -46,6 +46,7 @@ export interface ModalManager {
// State // State
newTaskModalOpen: boolean; newTaskModalOpen: boolean;
newTaskInitialDescription: string | null; newTaskInitialDescription: string | null;
newTaskInitialWorkflowId: string | null | undefined;
isPlanningOpen: boolean; isPlanningOpen: boolean;
planningInitialPlan: string | null; planningInitialPlan: string | null;
planningResumeSessionId: string | undefined; planningResumeSessionId: string | undefined;
@@ -88,7 +89,7 @@ export interface ModalManager {
anyModalOpen: boolean; anyModalOpen: boolean;
// Handlers // Handlers
openNewTask: () => void; openNewTask: (workflowId?: string | null) => void;
openNewTaskWithDescription: (description: string) => void; openNewTaskWithDescription: (description: string) => void;
closeNewTask: () => void; closeNewTask: () => void;
@@ -177,6 +178,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
const [newTaskModalOpen, setNewTaskModalOpen] = useState(false); const [newTaskModalOpen, setNewTaskModalOpen] = useState(false);
const [newTaskInitialDescription, setNewTaskInitialDescription] = useState<string | null>(null); const [newTaskInitialDescription, setNewTaskInitialDescription] = useState<string | null>(null);
const [newTaskInitialWorkflowId, setNewTaskInitialWorkflowId] = useState<string | null | undefined>(undefined);
const [isPlanningOpen, setIsPlanningOpen] = useState(false); const [isPlanningOpen, setIsPlanningOpen] = useState(false);
const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null); const [planningInitialPlan, setPlanningInitialPlan] = useState<string | null>(null);
const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined); const [planningResumeSessionId, setPlanningResumeSessionId] = useState<string | undefined>(undefined);
@@ -250,17 +252,20 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
modelOnboardingOpen, modelOnboardingOpen,
); );
const openNewTask = useCallback(() => { const openNewTask = useCallback((workflowId?: string | null) => {
setNewTaskInitialDescription(null); setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(workflowId);
setNewTaskModalOpen(true); setNewTaskModalOpen(true);
}, []); }, []);
const openNewTaskWithDescription = useCallback((description: string) => { const openNewTaskWithDescription = useCallback((description: string) => {
setNewTaskInitialDescription(description); setNewTaskInitialDescription(description);
setNewTaskInitialWorkflowId(undefined);
setNewTaskModalOpen(true); setNewTaskModalOpen(true);
}, []); }, []);
const closeNewTask = useCallback(() => { const closeNewTask = useCallback(() => {
setNewTaskModalOpen(false); setNewTaskModalOpen(false);
setNewTaskInitialDescription(null); setNewTaskInitialDescription(null);
setNewTaskInitialWorkflowId(undefined);
}, []); }, []);
const openPlanning = useCallback(() => { const openPlanning = useCallback(() => {
@@ -491,6 +496,7 @@ export function useModalManager(options: UseModalManagerOptions): ModalManager {
return { return {
newTaskModalOpen, newTaskModalOpen,
newTaskInitialDescription, newTaskInitialDescription,
newTaskInitialWorkflowId,
isPlanningOpen, isPlanningOpen,
planningInitialPlan, planningInitialPlan,
planningResumeSessionId, planningResumeSessionId,

View File

@@ -1641,6 +1641,22 @@ describe("GET /auth/status", () => {
expect(providerIds).not.toContain("anthropic"); expect(providerIds).not.toContain("anthropic");
expect(providerIds.filter((id: string) => id === "anthropic-subscription")).toHaveLength(1); 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" });
});
}); });
}); });

View File

@@ -3653,6 +3653,40 @@ describe("usage", () => {
expect(mockRequest).toHaveBeenCalledTimes(1); 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 () => { it("falls back to the xAI API-key validity card when CLI billing fails", async () => {
vi.stubEnv("GROK_API_KEY", "env-grok-key"); vi.stubEnv("GROK_API_KEY", "env-grok-key");
mockReadFile.mockImplementation(async (filePath: string) => { mockReadFile.mockImplementation(async (filePath: string) => {

View File

@@ -32,6 +32,20 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
const { router, options, store, getScopedStore, rethrowAsApiError } = ctx; const { router, options, store, getScopedStore, rethrowAsApiError } = ctx;
const authStorage = options?.authStorage; 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. // Use injected AuthStorage or fail gracefully if not provided.
// When running via the CLI/engine, AuthStorage is passed in via ServerOptions. // When running via the CLI/engine, AuthStorage is passed in via ServerOptions.
function getAuthStorage(): AuthStorageLike { function getAuthStorage(): AuthStorageLike {
@@ -618,7 +632,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
// storage.getApiKeyProviders may be absent/narrowed, but the catalog // storage.getApiKeyProviders may be absent/narrowed, but the catalog
// entries must still surface as present-but-unauthenticated. // 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); const apiKeyProviders = unionProviderCatalog(STATIC_API_KEY_PROVIDER_CATALOG, runtimeApiKeyProviders);
for (const p of apiKeyProviders) { for (const p of apiKeyProviders) {
let keyHint: string | undefined; let keyHint: string | undefined;

View File

@@ -1678,11 +1678,20 @@ async function fetchGrokCliBillingUsage(token: string, usage: ProviderUsage): Pr
const config = data?.config; const config = data?.config;
if (!config || typeof config !== "object") return false; 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 parsedReset = _parseResetTimestamp(config.billingPeriodEnd ?? config.currentPeriod?.end);
const isWeekly = config.currentPeriod?.type === "USAGE_PERIOD_TYPE_WEEKLY"; 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({ usage.windows.push({
label: isWeekly ? "Weekly (credits)" : "Credits", label: isWeekly ? "Weekly (credits)" : "Credits",
percentUsed: Math.min(100, Math.max(0, pctUsed)), percentUsed: Math.min(100, Math.max(0, pctUsed)),

View File

@@ -324,7 +324,7 @@ describe("createFusionAuthStorage", () => {
}); });
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ text: async () => JSON.stringify({
access_token: "refreshed-legacy-access-token", access_token: "refreshed-legacy-access-token",
refresh_token: "rotated-legacy-refresh-token", refresh_token: "rotated-legacy-refresh-token",
expires_in: 3600, expires_in: 3600,
@@ -400,7 +400,7 @@ describe("createFusionAuthStorage", () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ text: async () => JSON.stringify({
access_token: "refreshed-subscription-access-token", access_token: "refreshed-subscription-access-token",
refresh_token: "rotated-subscription-refresh-token", refresh_token: "rotated-subscription-refresh-token",
expires_in: 3600, expires_in: 3600,
@@ -469,7 +469,7 @@ describe("createFusionAuthStorage", () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ text: async () => JSON.stringify({
access_token: "proactively-refreshed-access-token", access_token: "proactively-refreshed-access-token",
refresh_token: "rotated-refresh-token", refresh_token: "rotated-refresh-token",
expires_in: 3600, expires_in: 3600,
@@ -516,7 +516,11 @@ describe("createFusionAuthStorage", () => {
expires: Date.now() - 60_000, 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(); const authStorage = createFusionAuthStorage();
@@ -732,7 +736,7 @@ describe("createFusionAuthStorage", () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ text: async () => JSON.stringify({
access_token: "refreshed-claude-access-token", access_token: "refreshed-claude-access-token",
refresh_token: "rotated-claude-refresh-token", refresh_token: "rotated-claude-refresh-token",
expires_in: 3600, expires_in: 3600,
@@ -788,7 +792,7 @@ describe("createFusionAuthStorage", () => {
globalThis.fetch = vi.fn().mockResolvedValue({ globalThis.fetch = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ expires_in: 3600 }), text: async () => JSON.stringify({ expires_in: 3600 }),
} as Response) as typeof fetch; } as Response) as typeof fetch;
const authStorage = createFusionAuthStorage(); 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; globalThis.fetch = fetchMock as typeof fetch;
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
@@ -843,8 +851,9 @@ describe("createFusionAuthStorage", () => {
const fetchMock = vi.fn().mockResolvedValue({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: async () => ({ text: async () => JSON.stringify({
access_token: "refreshed-claude-access-token", access_token: "refreshed-claude-access-token",
refresh_token: "rotated-claude-refresh-token",
expires_in: 3600, expires_in: 3600,
}), }),
} as Response); } 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({ const fetchMock = vi.fn().mockResolvedValue({
ok: true, ok: true,
json: () => new Promise((resolve) => { text: () => new Promise<string>((resolve) => {
resolveJson = resolve; resolveText = resolve;
}), }),
} as Response); } as Response);
globalThis.fetch = fetchMock as typeof fetch; globalThis.fetch = fetchMock as typeof fetch;
@@ -899,11 +908,11 @@ describe("createFusionAuthStorage", () => {
expires: Date.now() + 3_600_000, expires: Date.now() + 3_600_000,
}); });
resolveJson?.({ resolveText?.(JSON.stringify({
access_token: "stale-refresh-access-token", access_token: "stale-refresh-access-token",
refresh_token: "stale-refresh-refresh-token", refresh_token: "stale-refresh-refresh-token",
expires_in: 3600, expires_in: 3600,
}); }));
await expect(pendingRefresh).resolves.toBe("fresh-login-access-token"); await expect(pendingRefresh).resolves.toBe("fresh-login-access-token");
expect(authStorage.get("anthropic")).toEqual({ expect(authStorage.get("anthropic")).toEqual({

View File

@@ -46,36 +46,8 @@ apply so a single stuck token doesn't get hammered).
const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000; const OAUTH_REFRESH_BUFFER_MS = 5 * 60_000;
const ANTHROPIC_PROVIDER_ID = "anthropic"; const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; 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; 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 { export function getHomeDir(): string {
return process.env.HOME || process.env.USERPROFILE || homedir(); return process.env.HOME || process.env.USERPROFILE || homedir();
} }
@@ -179,116 +151,27 @@ function isSameOAuthCredentialIdentity(
&& left.expires === right.expires; && 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> { async function refreshAnthropicOAuthCredential(credential: StoredCredential): Promise<StoredCredential | undefined> {
const refresh = credential.refresh; if (credential.type !== "oauth" || !credential.refresh) {
if (!refresh) {
return undefined; return undefined;
} }
const scopes = getOAuthScopes(credential);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), OAUTH_REFRESH_TIMEOUT_MS);
try { try {
/* /*
FNXC:ClaudeOAuth 2026-06-13-22:46: FNXC:ClaudeOAuth 2026-07-14-14:25:
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. 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.
Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths.
*/ */
/* const provider = getOAuthProvider(ANTHROPIC_PROVIDER_ID);
FNXC:ClaudeOAuth 2026-07-05-18:52: if (!provider?.refreshToken) return undefined;
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 refreshed = await provider.refreshToken(credential as OAuthCredentials);
*/
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;
return { return {
...credential, ...credential,
type: "oauth", ...refreshed,
access, ...(credential.scopes ? { scopes: credential.scopes } : {}),
refresh: nextRefresh,
expires: parseExpiryMs(data, now),
scopes: parseScopes(data, scopes),
}; };
} catch { } catch {
return undefined; return undefined;
} finally {
clearTimeout(timeout);
} }
} }