Ctrl K
ComunidadGuías…

src/services/cliLogin.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<boolean>
  listMetadata: () => Promise<CliApiKeyMetadata[]>
  listOwnedKeys: () => Promise<CliOwnedKey[]>
  listBindings: (principalIds: string[]) => Promise<CliKeyBinding[]>
  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<Pick<CliApiKeyMetadata, 'name' | 'created_at'>>,
  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<CliLoginKeyDependencies, 'hasRequiredPermissions'>,
): 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<CliLoginKeyDependencies, 'createKey'>,
  clientName: string,
  now = new Date(),
): Promise<string> {
  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<CliLoginKeyPreparation> {
  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<Database>,
  userId: string,
): CliLoginKeyDependencies {
  return {
    hasRequiredPermissions: orgId => checkPermissions([
      'org.manage_apikeys',
      'org.update_user_roles',
    ], { orgId }),
    listMetadata: async () => {
      const { data, error } = await invokeCapgoApi<CliApiKeyMetadata[]>('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 }
    },
  }
}