refactor(FN-2550): split dashboard route orchestration into registrars
- Extract auth, model, and usage route registration into dedicated modules - Preserve existing mount order and orchestration behavior in the top-level routes entrypoint - Add route contract tests covering auth and usage endpoints to guard wiring regressions - Update routes README and address lint drift in the orchestrator refactor
This commit is contained in:
@@ -37,6 +37,9 @@ The context provides core cross-cutting plumbing:
|
||||
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
|
||||
- `register-messaging-scripts.ts` — scripts API and mailbox/message routes
|
||||
- `register-git-github.ts` — git/GitHub workflows and related helpers
|
||||
- `register-model-routes.ts` — `/models` endpoint, favorites projection, and `useClaudeCli` filtering for `pi-claude-cli` entries
|
||||
- `register-auth-routes.ts` — auth/provider domain (`/auth/status`, `/auth/login`, `/auth/logout`, `/auth/api-key`, `/auth/claude-cli`, `/providers/claude-cli/status`)
|
||||
- `register-usage-routes.ts` — `/usage` endpoint with `fetchAllProviderUsage(options?.authStorage)` integration
|
||||
- `register-files-terminal-workspaces.ts` — files, terminal, workspace file operations
|
||||
- `register-agent-core-routes.ts` — core agent CRUD, lookups, stats/org-tree, hierarchy aliases (`/agents/:id/children|employees`)
|
||||
- `register-agent-runtime-routes.ts` — agent runtime/control-plane, heartbeats/runs, access/permissions, soul/memory, revisions/budget/keys, task/inbox surfaces
|
||||
@@ -59,7 +62,11 @@ Express matches in registration order. Keep registrar and in-registrar route ord
|
||||
- `/mesh/state` must be registered before `/mesh/sync`
|
||||
- Discovery routes stay grouped after mesh routes
|
||||
- Inbound `/settings/sync-receive|auth-receive|auth-export` routes mount after discovery routes
|
||||
5. **Agent ordering constraints must stay intact**:
|
||||
5. **Auth/model/usage ordering constraints must stay intact**:
|
||||
- Keep `/models` registration before auth-dependent picker/settings flows that rely on consistent model filtering
|
||||
- Keep auth registrar routes grouped as currently mounted (status/diagnostic + mutation endpoints) so no wildcard handler can shadow `/providers/claude-cli/status`
|
||||
- Keep `/usage` mounted as a standalone registrar route (not under auth paths) with unchanged error mapping semantics
|
||||
6. **Agent ordering constraints must stay intact**:
|
||||
- `/agents/stats`, `/agents/org-tree`, `/agents/resolve/:shortname` before `/agents/:id`
|
||||
- `/agents/:id/runs/stop` before `/agents/:id/runs/:runId`
|
||||
- `/agents/:id/reflections/latest` before `/agents/:id/reflections`
|
||||
|
||||
436
packages/dashboard/src/routes/register-auth-routes.ts
Normal file
436
packages/dashboard/src/routes/register-auth-routes.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { isGhAvailable, isGhAuthenticated } from "@fusion/core";
|
||||
import { probeClaudeCli } from "../claude-cli-probe.js";
|
||||
import { ApiError, badRequest, conflict } from "../api-error.js";
|
||||
import { clearUsageCache } from "../usage.js";
|
||||
import { invalidateAllGlobalSettingsCaches } from "../project-store-resolver.js";
|
||||
import type { AuthStorageLike } from "../routes.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, rethrowAsApiError } = ctx;
|
||||
const authStorage = options?.authStorage;
|
||||
|
||||
// Use injected AuthStorage or fail gracefully if not provided.
|
||||
// When running via the CLI/engine, AuthStorage is passed in via ServerOptions.
|
||||
function getAuthStorage(): AuthStorageLike {
|
||||
if (!authStorage) {
|
||||
throw new Error("Authentication is not configured");
|
||||
}
|
||||
return authStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mask an API key for safe display.
|
||||
* - If key length <= 8: return 8 bullets (never reveal short keys)
|
||||
* - Otherwise: first 3 chars + 5 bullets + last 4 chars
|
||||
*/
|
||||
function maskApiKey(key: string): string {
|
||||
if (key.length <= 8) {
|
||||
return "••••••••";
|
||||
}
|
||||
return key.slice(0, 3) + "•••••" + key.slice(-4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Track in-progress login flows to prevent concurrent logins for the same provider.
|
||||
* Maps provider ID → AbortController for the active login.
|
||||
*/
|
||||
const loginInProgress = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Returns list of all providers with their authentication status and type.
|
||||
* Includes both OAuth-backed and API-key-backed providers.
|
||||
* Response: {
|
||||
* providers: [{ id, name, authenticated, type, keyHint? }],
|
||||
* ghCli: { available: boolean, authenticated: boolean }
|
||||
* }
|
||||
*/
|
||||
router.get("/auth/status", async (_req, res) => {
|
||||
try {
|
||||
const storage = getAuthStorage();
|
||||
storage.reload();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
const providers: { id: string; name: string; authenticated: boolean; type: "oauth" | "api_key" | "cli"; keyHint?: string }[] = oauthProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasAuth(p.id),
|
||||
type: "oauth" as const,
|
||||
}));
|
||||
|
||||
// Include API-key-backed providers if supported
|
||||
if (storage.getApiKeyProviders) {
|
||||
const apiKeyProviders = storage.getApiKeyProviders();
|
||||
for (const p of apiKeyProviders) {
|
||||
// Skip if already listed as an OAuth provider (avoid duplicates)
|
||||
if (providers.some((existing) => existing.id === p.id)) continue;
|
||||
let keyHint: string | undefined;
|
||||
if (storage.get) {
|
||||
const cred = storage.get(p.id);
|
||||
if (cred?.type === "api_key" && cred?.key) {
|
||||
keyHint = maskApiKey(cred.key);
|
||||
}
|
||||
}
|
||||
providers.push({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasApiKey ? storage.hasApiKey(p.id) : false,
|
||||
type: "api_key" as const,
|
||||
keyHint,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inject the synthetic "Anthropic — via Claude CLI" provider. Its
|
||||
// "authenticated" state is a product of three facts: the `claude`
|
||||
// binary must be on PATH, the user must have enabled useClaudeCli,
|
||||
// and the vendored extension must have loaded cleanly. We compute
|
||||
// them here once per /auth/status call so the provider list rendered
|
||||
// by onboarding + settings stays consistent with what a direct call
|
||||
// to /providers/claude-cli/status would return.
|
||||
if (store) {
|
||||
let enabled = false;
|
||||
try {
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
enabled = globalSettings.useClaudeCli === true;
|
||||
} catch {
|
||||
// Unreadable settings — fall through with enabled=false
|
||||
}
|
||||
const extension = options?.getClaudeCliExtensionStatus?.() ?? null;
|
||||
const binary = await probeClaudeCli();
|
||||
const extensionOk = extension === null || extension.status === "ok";
|
||||
providers.push({
|
||||
id: "claude-cli",
|
||||
name: "Anthropic — via Claude CLI",
|
||||
authenticated: enabled && binary.available && extensionOk,
|
||||
type: "cli" as const,
|
||||
});
|
||||
}
|
||||
|
||||
const ghCli = {
|
||||
available: isGhAvailable(),
|
||||
authenticated: isGhAuthenticated(),
|
||||
};
|
||||
|
||||
res.json({ providers, ghCli });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/providers/claude-cli/status
|
||||
* Dedicated diagnostic endpoint for the "Anthropic — via Claude CLI"
|
||||
* provider card. Runs three checks:
|
||||
* 1. `claude --version` binary probe (with short timeout)
|
||||
* 2. GlobalSettings.useClaudeCli toggle state
|
||||
* 3. Cached @fusion/pi-claude-cli extension resolution from the host
|
||||
*
|
||||
* Response fields are structured so the frontend can render a clear
|
||||
* "what's working, what isn't" breakdown without itself having to know
|
||||
* about pi internals.
|
||||
*/
|
||||
/**
|
||||
* POST /api/auth/claude-cli
|
||||
* Enable or disable the "Anthropic — via Claude CLI" synthetic provider.
|
||||
* Body: { enabled: boolean }
|
||||
*
|
||||
* Rather than add yet another settings API, this delegates to the
|
||||
* existing PUT /api/settings/global path — same cache invalidation,
|
||||
* same onUseClaudeCliToggled hook firing, same downstream skill
|
||||
* backfill behavior. The thin wrapper exists so the frontend provider
|
||||
* card has a shape-appropriate endpoint ("turn this provider on/off")
|
||||
* without calling a generic settings route.
|
||||
*
|
||||
* When `enabled=true` is requested we probe the claude binary first
|
||||
* and refuse if it's missing — saving the user from a confusing state
|
||||
* where the toggle is "on" but nothing actually works.
|
||||
*/
|
||||
router.post("/auth/claude-cli", async (req, res) => {
|
||||
try {
|
||||
if (!store) {
|
||||
throw new ApiError(500, "Settings store unavailable");
|
||||
}
|
||||
const enabled = req.body?.enabled;
|
||||
if (typeof enabled !== "boolean") {
|
||||
throw badRequest("enabled must be a boolean");
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
const binary = await probeClaudeCli();
|
||||
if (!binary.available) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
`Cannot enable Claude CLI routing: ${binary.reason ?? "claude binary not available"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot prior value so we only fire the toggle hook on an actual
|
||||
// transition — mirrors the logic in PUT /api/settings/global.
|
||||
let prev = false;
|
||||
try {
|
||||
const priorGlobal = await store.getGlobalSettingsStore().getSettings();
|
||||
prev = priorGlobal.useClaudeCli === true;
|
||||
} catch {
|
||||
// Unreadable prior — treat as false so a first enable still fires.
|
||||
}
|
||||
|
||||
const settings = await store.updateGlobalSettings({ useClaudeCli: enabled });
|
||||
invalidateAllGlobalSettingsCaches();
|
||||
const engineManager = options?.engineManager;
|
||||
if (engineManager) {
|
||||
for (const engine of engineManager.getAllEngines().values()) {
|
||||
engine.getTaskStore().getGlobalSettingsStore().invalidateCache();
|
||||
}
|
||||
}
|
||||
|
||||
const next = settings.useClaudeCli === true;
|
||||
if (options?.onUseClaudeCliToggled && prev !== next) {
|
||||
try {
|
||||
options.onUseClaudeCliToggled(prev, next);
|
||||
} catch (hookErr) {
|
||||
console.warn(
|
||||
`[auth/claude-cli] onUseClaudeCliToggled callback threw: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
enabled: next,
|
||||
// The pi-claude-cli extension is now always loaded; toggling
|
||||
// this setting only flips the /api/models filter, which takes
|
||||
// effect on the next picker fetch. No restart needed.
|
||||
restartRequired: false,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/providers/claude-cli/status", async (_req, res) => {
|
||||
try {
|
||||
const binary = await probeClaudeCli();
|
||||
let enabled = false;
|
||||
if (store) {
|
||||
try {
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
enabled = globalSettings.useClaudeCli === true;
|
||||
} catch {
|
||||
// Best-effort: unreadable settings still allow the binary probe
|
||||
// to surface, just with enabled=false.
|
||||
}
|
||||
}
|
||||
const extension = options?.getClaudeCliExtensionStatus?.() ?? null;
|
||||
|
||||
res.json({
|
||||
binary,
|
||||
enabled,
|
||||
extension,
|
||||
// Convenience field: the provider card considers everything "ready"
|
||||
// when the binary is available, the user has enabled the toggle,
|
||||
// AND the host loaded the extension without error. Surfacing this
|
||||
// keeps the UI render logic simple.
|
||||
ready:
|
||||
binary.available &&
|
||||
enabled &&
|
||||
(extension === null || extension.status === "ok"),
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Initiates OAuth login for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { url: string, instructions?: string }
|
||||
*
|
||||
* The endpoint starts the OAuth flow and returns the auth URL from the
|
||||
* onAuth callback. The client should open this URL in a new tab and
|
||||
* poll GET /api/auth/status to detect completion.
|
||||
*/
|
||||
router.post("/auth/login", async (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
throw badRequest("provider is required");
|
||||
}
|
||||
|
||||
// Prevent concurrent logins for the same provider
|
||||
if (loginInProgress.has(provider)) {
|
||||
throw conflict(`Login already in progress for ${provider}`);
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
const found = oauthProviders.find((p) => p.id === provider);
|
||||
if (!found) {
|
||||
throw badRequest(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
loginInProgress.set(provider, abortController);
|
||||
|
||||
// We need to get the URL from the onAuth callback before responding.
|
||||
// The login() call continues in the background until the user completes OAuth.
|
||||
let authResolve: (info: { url: string; instructions?: string }) => void;
|
||||
let authReject: (err: Error) => void;
|
||||
const authUrlPromise = new Promise<{ url: string; instructions?: string }>((resolve, reject) => {
|
||||
authResolve = resolve;
|
||||
authReject = reject;
|
||||
});
|
||||
|
||||
// Start login flow in background — don't await the full login
|
||||
const loginPromise = storage.login(provider, {
|
||||
onAuth: (info) => {
|
||||
authResolve({ url: info.url, instructions: info.instructions });
|
||||
},
|
||||
onPrompt: async (prompt) => {
|
||||
// Web UI cannot interactively prompt — return empty string if allowed
|
||||
if (prompt.allowEmpty) return "";
|
||||
return prompt.placeholder || "";
|
||||
},
|
||||
onProgress: () => {}, // no-op for web UI
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Race: either we get the auth URL or the login completes/fails first
|
||||
const timeout = setTimeout(() => {
|
||||
authReject(new Error("Login initiation timed out"));
|
||||
}, 30_000);
|
||||
|
||||
loginPromise
|
||||
.then(() => {
|
||||
// Login completed (user finished OAuth in browser)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Login failed — also reject auth URL if not yet received
|
||||
authReject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
loginInProgress.delete(provider);
|
||||
});
|
||||
|
||||
const authInfo = await authUrlPromise;
|
||||
clearTimeout(timeout);
|
||||
res.json({ url: authInfo.url, instructions: authInfo.instructions });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
// Clean up on error
|
||||
const provider = req.body?.provider;
|
||||
if (provider) loginInProgress.delete(provider);
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Removes credentials for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
router.post("/auth/logout", (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
throw badRequest("provider is required");
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
storage.logout(provider);
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/api-key
|
||||
* Save an API key for an API-key-backed provider.
|
||||
* Body: { provider: string, apiKey: string }
|
||||
* Response: { success: true }
|
||||
*
|
||||
* Validates the provider exists, is API-key-backed, and the key is non-empty.
|
||||
* Never returns the key in any response.
|
||||
*/
|
||||
router.post("/auth/api-key", (req, res) => {
|
||||
try {
|
||||
const { provider, apiKey } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
throw badRequest("provider is required");
|
||||
}
|
||||
if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) {
|
||||
throw badRequest("apiKey is required and must be a non-empty string");
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
|
||||
// Check that the storage supports API key management
|
||||
if (!storage.setApiKey) {
|
||||
throw badRequest("API key management is not supported");
|
||||
}
|
||||
|
||||
// Validate the provider is an API-key-backed provider
|
||||
const apiKeyProviders = storage.getApiKeyProviders?.() ?? [];
|
||||
const found = apiKeyProviders.find((p) => p.id === provider);
|
||||
if (!found) {
|
||||
throw badRequest(`Unknown API key provider: ${provider}`);
|
||||
}
|
||||
|
||||
storage.setApiKey(provider, apiKey.trim());
|
||||
clearUsageCache();
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/auth/api-key
|
||||
* Remove an API key for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
router.delete("/auth/api-key", (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
throw badRequest("provider is required");
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
if (!storage.clearApiKey) {
|
||||
throw badRequest("API key management is not supported");
|
||||
}
|
||||
|
||||
storage.clearApiKey(provider);
|
||||
clearUsageCache();
|
||||
res.json({ success: true });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
};
|
||||
61
packages/dashboard/src/routes/register-model-routes.ts
Normal file
61
packages/dashboard/src/routes/register-model-routes.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ApiError } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, runtimeLogger } = ctx;
|
||||
|
||||
router.get("/models", async (_req, res) => {
|
||||
// Always return 200 with empty array instead of 404 when no models available.
|
||||
// This ensures the frontend can handle empty states gracefully.
|
||||
if (!options?.modelRegistry) {
|
||||
res.json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
options.modelRegistry.refresh();
|
||||
let models = options.modelRegistry.getAvailable().map((m) => ({
|
||||
provider: m.provider,
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
reasoning: m.reasoning,
|
||||
contextWindow: m.contextWindow,
|
||||
}));
|
||||
|
||||
// Get favoriteProviders and favoriteModels from global settings
|
||||
let favoriteProviders: string[] = [];
|
||||
let favoriteModels: string[] = [];
|
||||
let useClaudeCli = false;
|
||||
if (store) {
|
||||
try {
|
||||
const globalStore = store.getGlobalSettingsStore();
|
||||
const globalSettings = await globalStore.getSettings();
|
||||
favoriteProviders = globalSettings.favoriteProviders ?? [];
|
||||
favoriteModels = globalSettings.favoriteModels ?? [];
|
||||
useClaudeCli = globalSettings.useClaudeCli === true;
|
||||
} catch {
|
||||
// Silently ignore settings errors - just return empty favorites
|
||||
}
|
||||
}
|
||||
|
||||
// The vendored pi-claude-cli extension registers its provider as
|
||||
// "pi-claude-cli" (distinct from "anthropic") whenever it loads.
|
||||
// When the toggle is OFF, hide those entries from pickers so users
|
||||
// don't see CLI-routed models they haven't opted into. When ON,
|
||||
// surface everything so the CLI-routed entries appear alongside any
|
||||
// direct provider auth the user has connected.
|
||||
if (!useClaudeCli) {
|
||||
models = models.filter((m) => m.provider !== "pi-claude-cli");
|
||||
}
|
||||
|
||||
res.json({ models, favoriteProviders, favoriteModels });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
runtimeLogger.child("models").warn(`Failed to load models: ${message}`);
|
||||
res.json({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
}
|
||||
});
|
||||
};
|
||||
27
packages/dashboard/src/routes/register-usage-routes.ts
Normal file
27
packages/dashboard/src/routes/register-usage-routes.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ApiError } from "../api-error.js";
|
||||
import { fetchAllProviderUsage } from "../usage.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
export const registerUsageRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, rethrowAsApiError } = ctx;
|
||||
|
||||
/**
|
||||
* GET /api/usage
|
||||
* Fetch AI provider subscription usage (Claude, Codex, Gemini).
|
||||
* Returns: { providers: ProviderUsage[] }
|
||||
*
|
||||
* Cached for 30 seconds to avoid hitting provider API rate limits.
|
||||
* Each provider's status is independent — one failure doesn't break all.
|
||||
*/
|
||||
router.get("/usage", async (_req, res) => {
|
||||
try {
|
||||
const providers = await fetchAllProviderUsage(options?.authStorage);
|
||||
res.json({ providers });
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err, "Failed to fetch usage data");
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user