import type { ConsoleClient } from '~/services/consoleClient' import type { Database } from '~/types/database.types' import { invokeCapgoApi } from '~/services/capgoApi' import { checkPermissions } from '~/services/permissions' export interface CliLoginOrganization { 'gid': string 'name': string 'role': string 'is_invite': boolean 'enforcing_2fa': boolean '2fa_has_access': boolean 'password_policy_config': { enabled?: boolean } | null 'password_has_access': boolean 'enforce_hashed_api_keys': boolean 'require_apikey_expiration': boolean 'max_apikey_expiration_days': number | null } export interface CliKeyBinding { role_name: string scope_type: string org_id: string | null app_id?: string | null channel_id?: string | null principal_id?: string | null expires_at?: string | null } export interface CliKeyPolicy { hashed: boolean expiresAt: string | null } export interface CliApiKeyMetadata { id: number name: string rbac_id: string | null created_at: string | null expires_at: string | null is_hashed_key: boolean global_permissions?: string[] } export interface CliOwnedKey { id: number name: string key: string | null rbac_id: string | null created_at: string | null expires_at: string | null } export interface CliLoginKeyDependencies { hasRequiredPermissions: (orgId: string) => Promise listMetadata: () => Promise listOwnedKeys: () => Promise listBindings: (principalIds: string[]) => Promise createKey: (input: { name: string hashed: boolean expires_at: string | null bindings: CliKeyBinding[] global_permissions: string[] }) => Promise<{ key: string | null }> } export interface CliSkippedOrganization { id: string name: string } export type CliLoginKeyPreparation = | { status: 'empty', skippedOrganizations: CliSkippedOrganization[] } | { status: 'ready' keyName: string secret: string eligibleOrgIds: string[] skippedOrganizations: CliSkippedOrganization[] policy: CliKeyPolicy reused: boolean } const CLI_KEY_NAME = /^Capgo CLI(?: \(([1-9]\d*)\))?$/ const DAY_MS = 86_400_000 const CLOCK_MARGIN_MS = 60_000 export function shouldShowCliLoginGuidance( keys: Array>, now = new Date(), ): boolean { const firstManagedKeyCreatedAt = keys .filter(key => CLI_KEY_NAME.test(key.name)) .map(key => new Date(key.created_at ?? '').getTime()) .filter(Number.isFinite) .sort((left, right) => left - right)[0] return firstManagedKeyCreatedAt === undefined || now.getTime() < firstManagedKeyCreatedAt + DAY_MS } export function roleForCliKey(role: string | null | undefined): 'org_admin' | 'org_super_admin' | null { if (role === 'owner' || role === 'org_super_admin') return 'org_super_admin' return role === 'org_admin' ? 'org_admin' : null } function managedNameNumber(name: string): number | null { const match = CLI_KEY_NAME.exec(name) if (!match) return null if (!match[1]) return 1 const value = Number(match[1]) return value >= 2 ? value : null } export function nextManagedCliKeyName(names: string[]): string { const used = new Set(names.map(managedNameNumber).filter((value): value is number => value !== null)) for (let value = 1; ; value++) { if (!used.has(value)) return value === 1 ? 'Capgo CLI' : `Capgo CLI (${value})` } } export function canonicalizeCliBindings(bindings: CliKeyBinding[]): string[] { return bindings.map(binding => [ binding.role_name, binding.scope_type, binding.org_id ?? '', binding.app_id ?? '', binding.channel_id ?? '', ].join('|')).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0)) } export function aggregateCliKeyPolicy(orgs: CliLoginOrganization[], now = new Date()): CliKeyPolicy { const requiresExpiration = orgs.some(org => org.require_apikey_expiration) if (!requiresExpiration) { return { hashed: orgs.some(org => org.enforce_hashed_api_keys), expiresAt: null, } } const limits = orgs .map(org => org.max_apikey_expiration_days) .filter((days): days is number => typeof days === 'number' && days > 0) const days = limits.length ? Math.min(...limits) : 365 return { hashed: orgs.some(org => org.enforce_hashed_api_keys), expiresAt: new Date(now.getTime() + days * DAY_MS - CLOCK_MARGIN_MS).toISOString(), } } export function isValidCliLoginSession(value: unknown): value is string { return typeof value === 'string' && /^[\w-]{22,}$/.test(value) } export function isCliAiQuery(value: unknown): boolean { const raw = Array.isArray(value) ? value[0] : value return typeof raw === 'string' && raw.replace(/\/+$/, '') === '1' } export function isCliLoginPath(path: string): boolean { return path.replace(/\/+$/, '') === '/login-cli' } /** MCP OAuth consent page: an agent-initiated login that must not detour into onboarding. */ export function isMcpAuthorizePath(path: string): boolean { return path.replace(/\/+$/, '') === '/oauth/authorize' } /** auth.md agent claim page: also agent-initiated, so it must not detour into onboarding either. */ export function isAgentClaimPath(path: string): boolean { return path.replace(/\/+$/, '') === '/agent/claim' } export function isMatchingCliLoginEvent( payload: { event?: string, channel?: string, description?: string }, session: string, ): boolean { return payload.event === 'User CLI login' && payload.channel === 'user-login' && payload.description === `cli-login:${session}` } export function getCliLoginDestination( organizationCount: number, apps: Array<{ app_id: string, need_onboarding: boolean }>, ): string { if (organizationCount !== 1 || apps.length !== 1 || !apps[0].need_onboarding) return '/dashboard' return `/app/${encodeURIComponent(apps[0].app_id)}/getting-started` } function orgPassesStaticChecks(org: CliLoginOrganization): boolean { if (org.is_invite || !roleForCliKey(org.role)) return false if (org.enforcing_2fa && !org['2fa_has_access']) return false return !(org.password_policy_config?.enabled && !org.password_has_access) } function sameStringArray(left: string[], right: string[]): boolean { return left.length === right.length && left.every((value, index) => value === right[index]) } function candidatePolicyAllows( key: CliOwnedKey, metadata: CliApiKeyMetadata, policy: CliKeyPolicy, now: Date, ): boolean { if (!key.key || metadata.is_hashed_key || policy.hashed) return false const expiresAt = key.expires_at ? new Date(key.expires_at).getTime() : null if (expiresAt !== null && expiresAt <= now.getTime()) return false if (!policy.expiresAt) return true return expiresAt !== null && expiresAt <= new Date(policy.expiresAt).getTime() + CLOCK_MARGIN_MS } /** Organizations a user may bind a new API key to (accepted membership, admin role, security policies met). */ export async function resolveCliKeyEligibility( organizations: CliLoginOrganization[], dependencies: Pick, ): Promise<{ eligible: CliLoginOrganization[], skippedOrganizations: CliSkippedOrganization[] }> { const checks = await Promise.all(organizations.map(async (organization) => { if (!orgPassesStaticChecks(organization)) return { organization, eligible: false } const eligible = await dependencies.hasRequiredPermissions(organization.gid).catch(() => false) return { organization, eligible } })) return { eligible: checks.filter(check => check.eligible).map(check => check.organization), skippedOrganizations: checks.filter(check => !check.eligible).map(check => ({ id: check.organization.gid, name: check.organization.name, })), } } /** OAuth-issued MCP keys always expire; clients re-run the login flow afterwards. */ export const MCP_OAUTH_KEY_MAX_DAYS = 90 export function mcpOAuthKeyExpiresAt(organizations: CliLoginOrganization[], now = new Date()): string { const defaultExpiry = now.getTime() + MCP_OAUTH_KEY_MAX_DAYS * DAY_MS - CLOCK_MARGIN_MS const policyExpiry = aggregateCliKeyPolicy(organizations, now).expiresAt return new Date(policyExpiry ? Math.min(defaultExpiry, new Date(policyExpiry).getTime()) : defaultExpiry).toISOString() } export function mcpOAuthKeyName(clientName: string): string { return `MCP ยท ${clientName.trim() || 'client'}`.slice(0, 120) } /** * Mint a dedicated API key for an MCP OAuth client, bound to the selected organizations with the * same role the CLI login uses. One key per authorization so each client can be revoked alone. */ export async function createMcpOAuthKey( organizations: CliLoginOrganization[], dependencies: Pick, clientName: string, now = new Date(), ): Promise { if (!organizations.length) throw new Error('Select at least one organization') const policy = aggregateCliKeyPolicy(organizations, now) const created = await dependencies.createKey({ name: mcpOAuthKeyName(clientName), hashed: policy.hashed, expires_at: mcpOAuthKeyExpiresAt(organizations, now), bindings: organizations.map(organization => ({ role_name: roleForCliKey(organization.role)!, scope_type: 'org', org_id: organization.gid, })), global_permissions: [], }) if (!created.key) throw new Error('MCP API key creation did not return a secret') return created.key } export async function prepareCliLoginKey( organizations: CliLoginOrganization[], dependencies: CliLoginKeyDependencies, now = new Date(), ): Promise { const { eligible, skippedOrganizations } = await resolveCliKeyEligibility(organizations, dependencies) if (!eligible.length) return { status: 'empty', skippedOrganizations } const expectedBindings: CliKeyBinding[] = eligible.map(organization => ({ role_name: roleForCliKey(organization.role)!, scope_type: 'org', org_id: organization.gid, })) const expectedCanonical = canonicalizeCliBindings(expectedBindings) const policy = aggregateCliKeyPolicy(eligible, now) const [metadata, ownedKeys] = await Promise.all([ dependencies.listMetadata(), dependencies.listOwnedKeys(), ]) const managedOwnedKeys = ownedKeys.filter(key => managedNameNumber(key.name) !== null) const metadataById = new Map(metadata.map(key => [key.id, key])) const principalIds = managedOwnedKeys.map(key => key.rbac_id).filter((id): id is string => !!id) const allBindings = await dependencies.listBindings(principalIds) const candidates = [...managedOwnedKeys].sort((left, right) => { const byName = managedNameNumber(left.name)! - managedNameNumber(right.name)! return byName || new Date(right.created_at ?? 0).getTime() - new Date(left.created_at ?? 0).getTime() }) for (const key of candidates) { const keyMetadata = metadataById.get(key.id) if (!keyMetadata || !Array.isArray(keyMetadata.global_permissions) || keyMetadata.global_permissions.length !== 0) continue const bindings = allBindings.filter(binding => binding.principal_id === key.rbac_id && (!binding.expires_at || new Date(binding.expires_at).getTime() > now.getTime())) if (!sameStringArray(canonicalizeCliBindings(bindings), expectedCanonical)) continue if (!candidatePolicyAllows(key, keyMetadata, policy, now)) continue return { status: 'ready', keyName: key.name, secret: key.key!, eligibleOrgIds: eligible.map(org => org.gid), skippedOrganizations, policy, reused: true, } } const keyName = nextManagedCliKeyName(ownedKeys.map(key => key.name)) const created = await dependencies.createKey({ name: keyName, hashed: policy.hashed, expires_at: policy.expiresAt, bindings: expectedBindings, global_permissions: [], }) if (!created.key) throw new Error('CLI API key creation did not return a secret') return { status: 'ready', keyName, secret: created.key, eligibleOrgIds: eligible.map(org => org.gid), skippedOrganizations, policy, reused: false, } } export function createCliLoginKeyDependencies( capgo: ConsoleClient, userId: string, ): CliLoginKeyDependencies { return { hasRequiredPermissions: orgId => checkPermissions([ 'org.manage_apikeys', 'org.update_user_roles', ], { orgId }), listMetadata: async () => { const { data, error } = await invokeCapgoApi('apikey', { client: capgo, method: 'GET', }) if (error) throw error return data ?? [] }, listOwnedKeys: async () => { const { data, error } = await capgo .from('apikeys') .select('id, name, key, rbac_id, created_at, expires_at') .eq('user_id', userId) if (error) throw error return data ?? [] }, listBindings: async (principalIds) => { if (!principalIds.length) return [] const { data, error } = await capgo .from('role_bindings') .select('principal_id, scope_type, org_id, app_id, channel_id, expires_at, roles(name)') .eq('principal_type', 'apikey') .in('principal_id', principalIds) if (error) throw error return (data ?? []).map((row) => { const relation = row.roles as { name?: string | null } | Array<{ name?: string | null }> | null const role = Array.isArray(relation) ? relation[0] : relation return { principal_id: row.principal_id, role_name: role?.name ?? '', scope_type: row.scope_type, org_id: row.org_id, app_id: row.app_id, channel_id: row.channel_id, expires_at: row.expires_at, } }) }, createKey: async (input) => { const { data, error } = await invokeCapgoApi<{ key?: string | null }>('apikey', { client: capgo, method: 'POST', body: input, }) if (error) throw error return { key: data?.key ?? null } }, } }