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}
|
addToast={addToast}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
initialDescription={modalManager.newTaskInitialDescription ?? ""}
|
initialDescription={modalManager.newTaskInitialDescription ?? ""}
|
||||||
|
initialWorkflowId={modalManager.newTaskInitialWorkflowId}
|
||||||
onPlanningMode={onPlanningMode}
|
onPlanningMode={onPlanningMode}
|
||||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 } : {})}
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user