diff --git a/drizzle/migrations/0010_lumpy_sentinels.sql b/drizzle/migrations/0010_lumpy_sentinels.sql new file mode 100644 index 0000000..3070666 --- /dev/null +++ b/drizzle/migrations/0010_lumpy_sentinels.sql @@ -0,0 +1,2 @@ +ALTER TABLE `provider_accounts` ADD `enabled` integer DEFAULT true NOT NULL;--> statement-breakpoint +CREATE INDEX `provider_accounts_enabled_idx` ON `provider_accounts` (`provider`,`enabled`); \ No newline at end of file diff --git a/drizzle/migrations/meta/0010_snapshot.json b/drizzle/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..1e84a8a --- /dev/null +++ b/drizzle/migrations/meta/0010_snapshot.json @@ -0,0 +1,506 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "fc6d4302-8d10-4426-9c31-23a01a8769ae", + "prevId": "3a673cab-a634-493e-8839-ecf31d02774c", + "tables": { + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "models_discovery_token": { + "name": "models_discovery_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_scope_json": { + "name": "provider_scope_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model_scope_json": { + "name": "model_scope_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_scope_json": { + "name": "account_scope_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "api_keys_key_unique": { + "name": "api_keys_key_unique", + "columns": ["key"], + "isUnique": true + }, + "api_keys_models_discovery_token_unique": { + "name": "api_keys_models_discovery_token_unique", + "columns": ["models_discovery_token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_states": { + "name": "oauth_states", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "provider_accounts": { + "name": "provider_accounts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_primary": { + "name": "is_primary", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_lock_token": { + "name": "refresh_lock_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_lock_expires_at": { + "name": "refresh_lock_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_at": { + "name": "last_refresh_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_refresh_status": { + "name": "last_refresh_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "provider_accounts_provider_idx": { + "name": "provider_accounts_provider_idx", + "columns": ["provider"], + "isUnique": false + }, + "provider_accounts_enabled_idx": { + "name": "provider_accounts_enabled_idx", + "columns": ["provider", "enabled"], + "isUnique": false + }, + "provider_accounts_primary_idx": { + "name": "provider_accounts_primary_idx", + "columns": ["provider", "is_primary"], + "isUnique": false + }, + "provider_accounts_primary_unique": { + "name": "provider_accounts_primary_unique", + "columns": ["provider"], + "isUnique": true, + "where": "\"provider_accounts\".\"is_primary\" = 1" + }, + "provider_accounts_provider_account_unique": { + "name": "provider_accounts_provider_account_unique", + "columns": ["provider", "account_id"], + "isUnique": true, + "where": "\"provider_accounts\".\"account_id\" is not null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "request_usage_buckets": { + "name": "request_usage_buckets", + "columns": { + "bucket_start": { + "name": "bucket_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "success_count": { + "name": "success_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "client_error_count": { + "name": "client_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "server_error_count": { + "name": "server_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "auth_error_count": { + "name": "auth_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "rate_limit_count": { + "name": "rate_limit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "proxy_error_count": { + "name": "proxy_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "upstream_error_count": { + "name": "upstream_error_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "total_latency_ms": { + "name": "total_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_latency_ms": { + "name": "max_latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_read_tokens": { + "name": "cache_read_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "cache_write_tokens": { + "name": "cache_write_tokens", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_request_at": { + "name": "last_request_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "request_usage_buckets_key_bucket_idx": { + "name": "request_usage_buckets_key_bucket_idx", + "columns": ["api_key_id", "bucket_start"], + "isUnique": false + }, + "request_usage_buckets_account_bucket_idx": { + "name": "request_usage_buckets_account_bucket_idx", + "columns": ["provider_account_id", "bucket_start"], + "isUnique": false + }, + "request_usage_buckets_bucket_idx": { + "name": "request_usage_buckets_bucket_idx", + "columns": ["bucket_start"], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "request_usage_buckets_bucket_start_api_key_id_provider_account_id_provider_endpoint_model_pk": { + "columns": [ + "bucket_start", + "api_key_id", + "provider_account_id", + "provider", + "endpoint", + "model" + ], + "name": "request_usage_buckets_bucket_start_api_key_id_provider_account_id_provider_endpoint_model_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index fb077e6..cbf8f7d 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1773857211769, "tag": "0009_jittery_silver_centurion", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1784655400876, + "tag": "0010_lumpy_sentinels", + "breakpoints": true } ] } diff --git a/public/admin/app-data.js b/public/admin/app-data.js index f34570d..eac9df1 100644 --- a/public/admin/app-data.js +++ b/public/admin/app-data.js @@ -26,6 +26,7 @@ const state = { token: readPersistedToken(), accounts: [], accountsById: new Map(), + providerStatuses: [], accountUsageById: new Map(), accountUsageWindowMs: DEFAULT_KEY_USAGE_WINDOW_MS, keys: [], @@ -510,7 +511,7 @@ function renderAccountScopeOptions(mode, selectedAccountIds = []) { ${escapeHtml(account.label || account.accountId || shortId(account.id))} ${account.isPrimary ? 'primary' : ""} - ${escapeHtml([shortId(account.id), account.expiresAt ? expiryCountdown(account.expiresAt) : ""].filter(Boolean).join(" · "))} + ${escapeHtml([shortId(account.id), account.enabled === false ? "disabled" : "", account.expiresAt ? expiryCountdown(account.expiresAt) : ""].filter(Boolean).join(" · "))} ` ) @@ -621,6 +622,7 @@ async function loadAccounts() { state.accounts = accountsResult.value.accounts || []; state.accountsById = usageMapFromList(state.accounts, "id"); + state.providerStatuses = accountsResult.value.providers || []; if (usageResult.status === "fulfilled") { if (typeof usageResult.value.windowMs === "number") { @@ -648,6 +650,8 @@ async function loadAccounts() { state.accounts = []; state.accountsById = new Map(); state.accountUsageById = new Map(); + state.providerStatuses = []; + $("#providers-status").innerHTML = ""; $("#accounts-list").innerHTML = `
${escapeHtml(e.message)}
`; toast(e.message, "error"); @@ -716,6 +720,39 @@ async function setPrimary(id) { } } +async function toggleProvider(provider, enabled) { + if (!enabled) { + const confirmed = await showConfirm( + "Disable Provider", + `Disable ${provider}? All ${provider} accounts will stop receiving proxy traffic and its models will disappear from model registries. You can re-enable it at any time.`, + "disable" + ); + if (!confirmed) return; + } + + const btn = document.querySelector( + `[data-action="toggle-provider"][data-provider="${provider}"]` + ); + if (btn) { + btn.disabled = true; + btn.innerHTML = ''; + } + try { + await api(`/admin/accounts/providers/${provider}`, { + method: "PATCH", + body: JSON.stringify({ enabled }), + }); + toast(enabled ? `${provider} enabled` : `${provider} disabled`); + await loadAccounts(); + } catch (e) { + toast(e.message, "error"); + if (btn) { + btn.disabled = false; + btn.textContent = enabled ? "enable" : "disable"; + } + } +} + async function deleteAccount(id) { const account = accountById(id); const name = account?.label || account?.accountId || id; @@ -1229,6 +1266,7 @@ function logout() { state.token = ""; state.accounts = []; state.accountsById = new Map(); + state.providerStatuses = []; state.accountUsageById = new Map(); state.accountUsageWindowMs = DEFAULT_KEY_USAGE_WINDOW_MS; state.keys = []; @@ -1245,6 +1283,7 @@ function logout() { state.dashboardRequestSeq = 0; $("#oauth-flow-active").style.display = "none"; $("#oauth-flow-active").innerHTML = ""; + $("#providers-status").innerHTML = ""; $("#dash-content").innerHTML = ""; syncDashboardWindowButtons(); syncAccountWindowButtons(); @@ -1307,6 +1346,7 @@ export { syncDashboardWindowButtons, syncScopedAccountAvailability, toast, + toggleProvider, tokenStatus, updateOAuthProviderUI, usageForKey, diff --git a/public/admin/app-render.js b/public/admin/app-render.js index 56d0036..67e4dd5 100644 --- a/public/admin/app-render.js +++ b/public/admin/app-render.js @@ -27,8 +27,45 @@ import { usageWindowLabel, } from "./app-data.js"; +function providerStatusItemHtml(status) { + const { provider, enabled, accountCount, enabledAccountCount } = status; + const countLabel = + enabled && enabledAccountCount < accountCount + ? `${enabledAccountCount}/${accountCount} enabled` + : `${accountCount} account${accountCount === 1 ? "" : "s"}`; + + return `
+ ${provider} + + + ${enabled ? "enabled" : "disabled"} + + ${countLabel} + + +
`; +} + +function renderProviderStatuses() { + const statuses = (state.providerStatuses || []).filter( + (status) => status.accountCount > 0 + ); + $("#providers-status").innerHTML = statuses.length + ? statuses.map(providerStatusItemHtml).join("") + : ""; +} + function accountCardHtml(account) { - const s = tokenStatus(account.expiresAt); + const disabled = account.enabled === false; + const s = disabled + ? { label: "disabled", class: "unknown" } + : tokenStatus(account.expiresAt); const name = account.label || account.accountId || account.id; const usage = accountUsageForId(account.id); const windowLabel = usageWindowLabel(state.accountUsageWindowMs); @@ -58,12 +95,13 @@ function accountCardHtml(account) { const meta = metadataHtml(account.metadata); - return `
+ return `
${account.provider} ${escapeHtml(name)} ${account.isPrimary ? 'primary' : ""} + ${disabled ? 'disabled' : ""}
${editBtn} @@ -88,6 +126,7 @@ function accountCardHtml(account) { function renderAccounts() { const { accounts } = state; + renderProviderStatuses(); $("#accounts-count").textContent = accounts.length ? `(${accounts.length})` : ""; diff --git a/public/admin/app.js b/public/admin/app.js index b9c37f3..29544f9 100644 --- a/public/admin/app.js +++ b/public/admin/app.js @@ -34,6 +34,7 @@ import { syncKeyWindowButtons, syncScopedAccountAvailability, toast, + toggleProvider, updateOAuthProviderUI, verifyToken, } from "./app-data.js"; @@ -45,6 +46,12 @@ import { setupSnippet, } from "./app-render.js"; +$("#providers-status").addEventListener("click", (e) => { + const button = e.target.closest('button[data-action="toggle-provider"]'); + if (!button) return; + toggleProvider(button.dataset.provider, button.dataset.enabled === "true"); +}); + $("#accounts-list").addEventListener("click", (e) => { const button = e.target.closest("button[data-action]"); if (button) { diff --git a/public/admin/index.html b/public/admin/index.html index 4bd8673..3da7663 100644 --- a/public/admin/index.html +++ b/public/admin/index.html @@ -172,6 +172,7 @@
+
diff --git a/public/admin/styles.css b/public/admin/styles.css index 1571a25..fcf0444 100644 --- a/public/admin/styles.css +++ b/public/admin/styles.css @@ -442,6 +442,48 @@ body { color: var(--text-tertiary); background: var(--bg-elevated); } +.badge-disabled { + color: var(--red); + background: var(--red-dim); +} + +.provider-status-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.provider-status-row:not(:empty) { + margin-bottom: 16px; +} +.provider-status-item { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); +} +.provider-status-item.disabled { + border-color: var(--red-dim); +} +.provider-status-item.disabled .badge { + opacity: 0.6; +} +.provider-status-meta { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--text-secondary); +} + +.card-disabled { + opacity: 0.65; +} +.card-disabled:hover { + opacity: 1; +} .status-dot { width: 7px; diff --git a/src/db/repositories/provider-accounts.ts b/src/db/repositories/provider-accounts.ts index 50967ec..db4caa2 100644 --- a/src/db/repositories/provider-accounts.ts +++ b/src/db/repositories/provider-accounts.ts @@ -1,7 +1,7 @@ import { and, desc, eq, gt, inArray, isNull, lte, or } from "drizzle-orm"; import type { Database } from "../index"; -import { providerAccounts, type Provider } from "../schema"; +import { providerAccounts, providers, type Provider } from "../schema"; import { parseProviderAccountMetadata, serializeProviderAccountMetadata, @@ -14,6 +14,7 @@ export type ProviderAccountRecord = { label: string | null; accountId: string | null; isPrimary: boolean; + enabled: boolean; accessToken: string; refreshToken: string; refreshLockToken: string | null; @@ -34,6 +35,7 @@ const toRecord = ( label: row.label, accountId: row.accountId, isPrimary: row.isPrimary, + enabled: row.enabled, accessToken: row.accessToken, refreshToken: row.refreshToken, refreshLockToken: row.refreshLockToken, @@ -67,7 +69,12 @@ export const listConfiguredProviders = async ( provider: providerAccounts.provider, }) .from(providerAccounts) - .where(eq(providerAccounts.isPrimary, true)); + .where( + and( + eq(providerAccounts.isPrimary, true), + eq(providerAccounts.enabled, true) + ) + ); return rows.map((row) => row.provider); }; @@ -119,7 +126,8 @@ export const findPrimaryProviderAccount = async ( const row = await database.query.providerAccounts.findFirst({ where: and( eq(providerAccounts.provider, provider), - eq(providerAccounts.isPrimary, true) + eq(providerAccounts.isPrimary, true), + eq(providerAccounts.enabled, true) ), orderBy: desc(providerAccounts.createdAt), }); @@ -163,6 +171,16 @@ const hasPrimaryProviderAccount = async ( return Boolean(primary); }; +const isProviderEnabledForNewAccount = async ( + database: Database, + provider: Provider +): Promise => { + const account = await database.query.providerAccounts.findFirst({ + where: eq(providerAccounts.provider, provider), + }); + return account?.enabled ?? true; +}; + const isUniqueConstraintError = (error: unknown): boolean => error instanceof Error && error.message.toLowerCase().includes("unique constraint failed"); @@ -236,12 +254,17 @@ export const upsertProviderAccount = async ( database, input.provider )); + const enabled = await isProviderEnabledForNewAccount( + database, + input.provider + ); const insertValues: typeof providerAccounts.$inferInsert = { id: crypto.randomUUID(), provider: input.provider, label: input.label === undefined ? null : input.label, accountId: input.accountId, isPrimary, + enabled, accessToken: input.accessToken, refreshToken: input.refreshToken, refreshLockToken: null, @@ -620,3 +643,53 @@ export const setPrimaryProviderAccount = async ( throw error; } }; + +export type ProviderStatus = { + provider: Provider; + enabled: boolean; + accountCount: number; + enabledAccountCount: number; +}; + +export const listProviderStatuses = async ( + database: Database +): Promise => { + const rows = await database + .select({ + provider: providerAccounts.provider, + enabled: providerAccounts.enabled, + }) + .from(providerAccounts); + + return providers.map((provider) => { + const accounts = rows.filter((row) => row.provider === provider); + const enabledAccountCount = accounts.filter((row) => row.enabled).length; + return { + provider, + enabled: enabledAccountCount > 0, + accountCount: accounts.length, + enabledAccountCount, + }; + }); +}; + +export const setProviderAccountsEnabled = async ( + database: Database, + provider: Provider, + enabled: boolean, + now: number +): Promise => { + await database.transaction(async (tx) => { + await tx + .update(providerAccounts) + .set({ enabled, updatedAt: now }) + .where(eq(providerAccounts.provider, provider)); + }); + + const statuses = await listProviderStatuses(database); + const status = statuses.find((item) => item.provider === provider); + if (!status) { + throw new Error(`Unknown provider: ${provider}`); + } + return status; +}; diff --git a/src/db/schema.ts b/src/db/schema.ts index c383886..7bc14f0 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -21,6 +21,7 @@ export const providerAccounts = sqliteTable( isPrimary: integer("is_primary", { mode: "boolean" }) .notNull() .default(false), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), accessToken: text("access_token").notNull(), refreshToken: text("refresh_token").notNull(), refreshLockToken: text("refresh_lock_token"), @@ -36,6 +37,7 @@ export const providerAccounts = sqliteTable( }, (table) => [ index("provider_accounts_provider_idx").on(table.provider), + index("provider_accounts_enabled_idx").on(table.provider, table.enabled), index("provider_accounts_primary_idx").on(table.provider, table.isPrimary), uniqueIndex("provider_accounts_primary_unique") .on(table.provider) diff --git a/src/domain/providers/provider-service.ts b/src/domain/providers/provider-service.ts index f477216..9500754 100644 --- a/src/domain/providers/provider-service.ts +++ b/src/domain/providers/provider-service.ts @@ -356,7 +356,7 @@ export const getRoutableProviderAccount = async ( const account = pickPreferredProviderAccount( (await findProviderAccountsByIds(database, allowedAccountIds)).filter( - (candidate) => candidate.provider === provider + (candidate) => candidate.provider === provider && candidate.enabled ) ); if (!account) { diff --git a/src/http/routes/admin-accounts.ts b/src/http/routes/admin-accounts.ts index 44afdf2..15660ac 100644 --- a/src/http/routes/admin-accounts.ts +++ b/src/http/routes/admin-accounts.ts @@ -17,6 +17,8 @@ import { deleteProviderAccount, findProviderAccountById, listProviderAccounts, + listProviderStatuses, + setProviderAccountsEnabled, setPrimaryProviderAccount, type ProviderAccountRecord, updateProviderAccountProfile, @@ -40,6 +42,10 @@ const oauthProviderParamsSchema = z.strictObject({ provider: z.enum(providers), }); +const updateProviderStatusBodySchema = z.strictObject({ + enabled: z.boolean(), +}); + const oauthStartBodySchema = z.strictObject({ options: z.record(z.string(), z.unknown()).optional(), }); @@ -116,6 +122,7 @@ const toAdminAccountView = ( label: account.label, accountId: account.accountId, isPrimary: account.isPrimary, + enabled: account.enabled, metadata: account.metadata, expiresAt: account.expiresAt, lastRefreshAt: account.lastRefreshAt, @@ -126,9 +133,35 @@ const toAdminAccountView = ( export const adminAccountsRoutes = new Hono() .get("/", async (context) => { - const accounts = await listProviderAccounts(db); - return context.json({ accounts: accounts.map(toAdminAccountView) }); + const [accounts, providerStatuses] = await Promise.all([ + listProviderAccounts(db), + listProviderStatuses(db), + ]); + return context.json({ + accounts: accounts.map(toAdminAccountView), + providers: providerStatuses, + }); + }) + .get("/providers", async (context) => { + const providerStatuses = await listProviderStatuses(db); + return context.json({ providers: providerStatuses }); }) + .patch( + "/providers/:provider", + zValidator("param", oauthProviderParamsSchema), + zValidator("json", updateProviderStatusBodySchema), + async (context) => { + const { provider } = context.req.valid("param"); + const { enabled } = context.req.valid("json"); + const status = await setProviderAccountsEnabled( + db, + provider, + enabled, + Date.now() + ); + return context.json({ provider: status, updated: true }); + } + ) .get( "/usage", zValidator("query", usageWindowQuerySchema), diff --git a/tests/db/provider-accounts.test.ts b/tests/db/provider-accounts.test.ts new file mode 100644 index 0000000..944465e --- /dev/null +++ b/tests/db/provider-accounts.test.ts @@ -0,0 +1,138 @@ +import { createClient } from "@libsql/client"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { drizzle } from "drizzle-orm/libsql"; +import { migrate } from "drizzle-orm/libsql/migrator"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { Database } from "../../src/db"; +import { + findPrimaryProviderAccount, + listConfiguredProviders, + listProviderAccounts, + listProviderStatuses, + setProviderAccountsEnabled, + upsertProviderAccount, +} from "../../src/db/repositories/provider-accounts"; +import { providerAccounts } from "../../src/db/schema"; +import * as schema from "../../src/db/schema"; +import { getRoutableProviderAccount } from "../../src/domain/providers/provider-service"; + +describe("provider account enablement", () => { + let client: ReturnType | undefined; + let database: Database; + let databaseDirectory: string; + + beforeEach(async () => { + databaseDirectory = await mkdtemp( + join(tmpdir(), "kleis-provider-accounts-") + ); + const databasePath = join(databaseDirectory, "test.db"); + client = createClient({ url: `file:${databasePath}` }); + database = drizzle(client, { schema }); + await migrate(database, { migrationsFolder: "./drizzle/migrations" }); + + const now = Date.now(); + await database.insert(providerAccounts).values([ + { + id: "copilot-primary", + provider: "copilot", + isPrimary: true, + accessToken: "access-primary", + refreshToken: "refresh-primary", + expiresAt: now + 60_000, + createdAt: now, + updatedAt: now, + }, + { + id: "copilot-secondary", + provider: "copilot", + isPrimary: false, + accessToken: "access-secondary", + refreshToken: "refresh-secondary", + expiresAt: now + 60_000, + createdAt: now - 1, + updatedAt: now, + }, + { + id: "codex-primary", + provider: "codex", + isPrimary: true, + accessToken: "access-codex", + refreshToken: "refresh-codex", + expiresAt: now + 60_000, + createdAt: now, + updatedAt: now, + }, + ]); + }); + + afterEach(async () => { + client?.close(); + await rm(databaseDirectory, { recursive: true, force: true }).catch( + () => undefined + ); + }); + + test("disables every account and excludes the provider from discovery and routing", async () => { + const now = Date.now(); + const status = await setProviderAccountsEnabled( + database, + "copilot", + false, + now + ); + + expect(status).toEqual({ + provider: "copilot", + enabled: false, + accountCount: 2, + enabledAccountCount: 0, + }); + expect( + (await listProviderAccounts(database)) + .filter((account) => account.provider === "copilot") + .every((account) => !account.enabled) + ).toBe(true); + expect(await listConfiguredProviders(database)).toEqual(["codex"]); + expect(await findPrimaryProviderAccount(database, "copilot")).toBeNull(); + expect( + await getRoutableProviderAccount(database, "copilot", now) + ).toBeNull(); + expect( + await getRoutableProviderAccount(database, "copilot", now, { + allowedAccountIds: ["copilot-secondary"], + }) + ).toBeNull(); + }); + + test("new accounts inherit disabled state and re-enabling restores routing", async () => { + const now = Date.now(); + await setProviderAccountsEnabled(database, "copilot", false, now); + + const created = await upsertProviderAccount(database, { + provider: "copilot", + accountId: "new-account", + accessToken: "access-new", + refreshToken: "refresh-new", + expiresAt: now + 60_000, + metadata: null, + now, + }); + expect(created.enabled).toBe(false); + + const status = await setProviderAccountsEnabled( + database, + "copilot", + true, + now + 1 + ); + expect(status.enabledAccountCount).toBe(3); + expect(await listConfiguredProviders(database)).toContain("copilot"); + expect( + (await getRoutableProviderAccount(database, "copilot", now + 1))?.id + ).toBe("copilot-primary"); + expect(await listProviderStatuses(database)).toContainEqual(status); + }); +});