diff --git a/apps/api/src/services/organization/automation-key.ts b/apps/api/src/services/organization/automation-key.ts index 0da75eb6..34e2e582 100644 --- a/apps/api/src/services/organization/automation-key.ts +++ b/apps/api/src/services/organization/automation-key.ts @@ -13,6 +13,7 @@ import { getActiveOrganizationApiKeyByHash, getOrganizationApiKeyById, listOrganizationApiKeys, + rotateOrganizationApiKeyIfActive, updateOrganizationApiKey } from '@cio/db/queries/organization'; import { assertOrganizationAutomationKeyCreationAllowed } from './automation-usage'; @@ -135,16 +136,26 @@ export async function rotateOrganizationApiKeyService(organizationId: string, ke throw new AppError('Organization API key not found', ErrorCodes.ORGANIZATION_API_KEY_NOT_FOUND, 404); } + if (existing.revokedAt) { + throw new AppError( + 'Cannot rotate a revoked organization API key', + ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, + 400 + ); + } + const secret = generateOrganizationApiKeySecret(existing.type); - const updated = await updateOrganizationApiKey(organizationId, keyId, { + const updated = await rotateOrganizationApiKeyIfActive(organizationId, keyId, { secretPrefix: getSecretPrefix(secret), - secretHash: hashSecret(secret), - revokedAt: null, - lastUsedAt: null + secretHash: hashSecret(secret) }); if (!updated) { - throw new AppError('Failed to rotate organization API key', ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, 500); + throw new AppError( + 'Organization API key was revoked before rotation completed', + ErrorCodes.ORGANIZATION_API_KEY_ROTATE_FAILED, + 409 + ); } return { diff --git a/apps/api/src/services/organization/automation-usage.ts b/apps/api/src/services/organization/automation-usage.ts index c41fb960..83dbc11d 100644 --- a/apps/api/src/services/organization/automation-usage.ts +++ b/apps/api/src/services/organization/automation-usage.ts @@ -6,7 +6,6 @@ import { countOrganizationAutomationUsageSinceByKey, createOrganizationAutomationUsage, getActiveOrganizationPlan, - getOrganizationAutomationCreditsUsedSince, listRecentOrganizationAutomationUsage } from '@cio/db/queries/organization'; import type { TOrganizationApiKey, TOrganizationApiKeyType, TPlan } from '@db/types'; @@ -15,7 +14,6 @@ import { getMcpAutomationCategory, canUsePublicApi, getMcpAutomationLimits, - MCP_TOOL_CREDIT_COST, type TMcpToolName } from '@cio/utils/plans'; @@ -24,9 +22,6 @@ export type OrganizationAutomationUsageSummary = { planName: TPlan; billingPeriodStart: string; billingPeriodEnd: string; - monthlyCreditsIncluded: number; - monthlyCreditsUsed: number; - monthlyCreditsRemaining: number; activeKeys: number; maxActiveKeys: number; rateLimits: ReturnType['rateLimits']; @@ -104,9 +99,6 @@ export async function getOrganizationAutomationUsageSummaryService( planName, billingPeriodStart: billingPeriodStart.toISOString(), billingPeriodEnd: billingPeriodEnd.toISOString(), - monthlyCreditsIncluded: 0, - monthlyCreditsUsed: 0, - monthlyCreditsRemaining: 0, activeKeys: await countActiveOrganizationApiKeys(organizationId, type), maxActiveKeys: 0, rateLimits: { @@ -118,8 +110,7 @@ export async function getOrganizationAutomationUsageSummaryService( } const limits = getMcpAutomationLimits(planName); - const [monthlyCreditsUsed, activeKeys, recentActions] = await Promise.all([ - getOrganizationAutomationCreditsUsedSince(organizationId, type, billingPeriodStart.toISOString()), + const [activeKeys, recentActions] = await Promise.all([ countActiveOrganizationApiKeys(organizationId, type), listRecentOrganizationAutomationUsage(organizationId, type, 10) ]); @@ -129,9 +120,6 @@ export async function getOrganizationAutomationUsageSummaryService( planName, billingPeriodStart: billingPeriodStart.toISOString(), billingPeriodEnd: billingPeriodEnd.toISOString(), - monthlyCreditsIncluded: limits.monthlyCredits, - monthlyCreditsUsed, - monthlyCreditsRemaining: Math.max(limits.monthlyCredits - monthlyCreditsUsed, 0), activeKeys, maxActiveKeys: limits.maxActiveKeys, rateLimits: limits.rateLimits, @@ -151,24 +139,13 @@ export async function assertMcpAutomationUsageAllowed( const limits = getMcpAutomationLimits(planName); const category = getMcpAutomationCategory(toolName); const now = new Date(); - const billingPeriodStart = getBillingPeriodStart(now).toISOString(); const minuteWindowStart = getMinuteWindowStart(now).toISOString(); - const [creditsUsed, keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([ - getOrganizationAutomationCreditsUsedSince(automationKey.organizationId, automationKey.type, billingPeriodStart), + const [keyRequestsInWindow, orgRequestsInWindow] = await Promise.all([ countOrganizationAutomationUsageSinceByKey(automationKey.id, category, minuteWindowStart), countOrganizationAutomationUsageSince(automationKey.organizationId, automationKey.type, category, minuteWindowStart) ]); - const creditsNeeded = MCP_TOOL_CREDIT_COST[toolName]; - if (creditsUsed + creditsNeeded > limits.monthlyCredits) { - throw new AppError( - 'Monthly automation credits exhausted for this organization', - ErrorCodes.AUTOMATION_CREDIT_LIMIT_EXCEEDED, - 429 - ); - } - const keyLimit = category === 'read' ? limits.rateLimits.perKey.readPerMinute @@ -199,7 +176,7 @@ export async function recordMcpAutomationUsage( type: automationKey.type, action: toolName, category: getMcpAutomationCategory(toolName), - creditsConsumed: MCP_TOOL_CREDIT_COST[toolName], + creditsConsumed: 0, metadata }); } diff --git a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts index 3d5a037f..329598ec 100644 --- a/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts +++ b/apps/dashboard/src/lib/features/automation/api/automation.svelte.ts @@ -99,6 +99,12 @@ class AutomationApi extends BaseApiWithErrors { requestFn: () => classroomio.organization.automation.keys[':keyId'].rotate.$post({ param: { keyId } }), logContext: 'rotating automation key', onSuccess: (response) => { + const existingKey = this.keys.find((key) => key.id === response.data.key.id); + if (existingKey?.revokedAt) { + this.generatedSecret = null; + return; + } + this.generatedSecret = response.data.secret; this.keys = this.keys.map((key) => (key.id === response.data.key.id ? response.data.key : key)); snackbar.success('snackbar.automation.rotated'); diff --git a/apps/dashboard/src/lib/features/automation/pages/mcp.svelte b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte index 710e423c..144649ef 100644 --- a/apps/dashboard/src/lib/features/automation/pages/mcp.svelte +++ b/apps/dashboard/src/lib/features/automation/pages/mcp.svelte @@ -21,7 +21,6 @@ import * as Tabs from '@cio/ui/base/tabs'; import { InputField } from '@cio/ui/custom/input-field'; import { IconButton } from '@cio/ui/custom/icon-button'; - import CoinsIcon from '@lucide/svelte/icons/coins'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import GaugeIcon from '@lucide/svelte/icons/gauge'; import KeyIcon from '@lucide/svelte/icons/key'; @@ -32,6 +31,7 @@ let activeSetupTab = $state('cursor'); let generatedSecret = $state(null); + let generatedSecretKeyId = $state(null); let isCreateKeyModalOpen = $state(false); let keyLabel = $state(''); @@ -52,6 +52,7 @@ if (!result) return; generatedSecret = automationApi.generatedSecret; + generatedSecretKeyId = result.key.id; automationApi.clearGeneratedSecret(); isCreateKeyModalOpen = false; resetCreateKeyModal(); @@ -59,13 +60,25 @@ async function onRevokeKey(keyId: string) { if (!confirm(t.get('automation.keys.revoke_confirm'))) return; - await automationApi.revokeKey(keyId); + + const result = await automationApi.revokeKey(keyId); + if (result && generatedSecretKeyId === keyId) { + generatedSecret = null; + generatedSecretKeyId = null; + } } async function onRotateKey(keyId: string) { if (!confirm(t.get('automation.keys.rotate_confirm'))) return; - await automationApi.rotateKey(keyId); + + const result = await automationApi.rotateKey(keyId); + if (!result || result.key.revokedAt) return; + + const key = automationApi.keys.find((entry) => entry.id === keyId); + if (!key || key.revokedAt) return; + generatedSecret = automationApi.generatedSecret; + generatedSecretKeyId = keyId; automationApi.clearGeneratedSecret(); } const mcpKeys = $derived(automationApi.keys.filter((key) => key.type === 'mcp')); @@ -73,13 +86,6 @@ if (!automationApi.usage) return []; return [ - { - icon: CoinsIcon, - title: t.get('automation.mcp.usage.credits_title'), - percentage: automationApi.usage.monthlyCreditsRemaining, - description: `${t.get('automation.mcp.usage.used_label')} ${automationApi.usage.monthlyCreditsUsed} / ${automationApi.usage.monthlyCreditsIncluded}`, - hidePercentage: true - }, { icon: KeyIcon, title: t.get('automation.mcp.usage.keys_title'), @@ -98,7 +104,6 @@ }); const isLastUsageCard = (index: number) => index === usageCards.length - 1; - const hasActiveMcpKey = $derived(mcpKeys.some((key) => !key.revokedAt)); @@ -112,7 +117,7 @@ {/if} {#if $isOrgAdmin && automationApi.usage} -
+
{#each usageCards as card, i (i)} - {#if hasActiveMcpKey} - + {$t('automation.mcp.setup.title')} @@ -277,7 +281,6 @@ - {/if} {$t('automation.tabs.mcp')} {$t('automation.mcp.page_subtitle')} + + + diff --git a/packages/db/src/queries/organization/automation-key.ts b/packages/db/src/queries/organization/automation-key.ts index a99b6968..4598a6bc 100644 --- a/packages/db/src/queries/organization/automation-key.ts +++ b/packages/db/src/queries/organization/automation-key.ts @@ -119,3 +119,33 @@ export const updateOrganizationApiKey = async ( throw new Error('Failed to update organization API key'); } }; + +export const rotateOrganizationApiKeyIfActive = async ( + organizationId: string, + keyId: string, + data: Pick +): Promise => { + try { + const [row] = await db + .update(schema.organizationApiKey) + .set({ + secretPrefix: data.secretPrefix, + secretHash: data.secretHash, + lastUsedAt: null, + updatedAt: new Date().toISOString() + }) + .where( + and( + eq(schema.organizationApiKey.organizationId, organizationId), + eq(schema.organizationApiKey.id, keyId), + isNull(schema.organizationApiKey.revokedAt) + ) + ) + .returning(); + + return row || null; + } catch (error) { + console.error('rotateOrganizationApiKeyIfActive error:', error); + throw new Error('Failed to rotate organization API key'); + } +}; diff --git a/packages/utils/src/plans/automation.ts b/packages/utils/src/plans/automation.ts index aa9cf240..74b8d8d2 100644 --- a/packages/utils/src/plans/automation.ts +++ b/packages/utils/src/plans/automation.ts @@ -36,7 +36,6 @@ export type TMcpToolName = keyof typeof MCP_TOOL_CREDIT_COST; export type TAutomationUsageCategory = (typeof AUTOMATION_USAGE_CATEGORY)[keyof typeof AUTOMATION_USAGE_CATEGORY]; export type McpAutomationLimits = { - monthlyCredits: number; maxActiveKeys: number; rateLimits: { perKey: { @@ -54,7 +53,6 @@ export type McpAutomationLimits = { export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record = { [PLAN.BASIC]: { - monthlyCredits: 20, maxActiveKeys: 1, rateLimits: { perKey: { @@ -70,7 +68,6 @@ export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record } }, [PLAN.EARLY_ADOPTER]: { - monthlyCredits: 500, maxActiveKeys: 5, rateLimits: { perKey: { @@ -86,7 +83,6 @@ export const MCP_AUTOMATION_LIMITS_BY_PLAN: Record } }, [PLAN.ENTERPRISE]: { - monthlyCredits: 5000, maxActiveKeys: 25, rateLimits: { perKey: { diff --git a/packages/utils/src/plans/data.ts b/packages/utils/src/plans/data.ts index 03de958d..9e5ac5c3 100644 --- a/packages/utils/src/plans/data.ts +++ b/packages/utils/src/plans/data.ts @@ -15,7 +15,7 @@ export default { '20 Students', 'Advanced Course Builder (no video upload)', '500K AI credits / month', - 'MCP access with 20 automation credits / month', + 'MCP access', 'ClassroomIO branding' ], CTA: { @@ -41,7 +41,7 @@ export default { '10K Students', 'Course Builder with Video Upload & Certificate', '3M AI credits / month', - 'MCP access with 500 automation credits / month', + 'MCP access', 'Custom Branding', 'Custom Domain', 'API with limits',