Ctrl K
ComunidadGuías…

src/services/apikeys.ts

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

Archivo original ↗Preguntar a KiBot
import type { TableColumn } from '~/components/comp_def'
import type { ConsoleClient } from '~/services/consoleClient'
import type { DialogV2Button, DialogV2Options } from '~/stores/dialogv2'
import type { Database } from '~/types/database.types'
import { invokeCapgoApi } from '~/services/capgoApi'

interface ApiKeyLoadScope {
  userId: string
  orgId?: string | null
  appId?: string | null
  /** Distinguishes loads of a specific named key (AI onboarding) from any usable key. */
  name?: string | null
  /** Distinguishes loads restricted to keys with these app roles. */
  appRoles?: readonly string[]
}

const inFlightApiKeyLoads = new Map<string, Promise<string | null>>()

export function shareInFlightApiKeyLoad(
  scope: ApiKeyLoadScope,
  load: () => Promise<string | null>,
): Promise<string | null> {
  const scopeKey = JSON.stringify([scope.userId, scope.orgId ?? null, scope.appId ?? null, scope.name ?? null, scope.appRoles ?? null])
  const existingLoad = inFlightApiKeyLoads.get(scopeKey)
  if (existingLoad)
    return existingLoad

  const newLoad = Promise.resolve().then(load).finally(() => {
    if (inFlightApiKeyLoads.get(scopeKey) === newLoad)
      inFlightApiKeyLoads.delete(scopeKey)
  })
  inFlightApiKeyLoads.set(scopeKey, newLoad)
  return newLoad
}

export async function createDefaultApiKey(
  capgo: ConsoleClient<Database>,
  name: string,
  options: {
    orgId?: string | null
    appId?: string | null
    /** App-scoped keys only: the app role to bind (default `app_admin`). */
    appRole?: string
    hashed?: boolean
  } = {},
) {
  let orgId = options.orgId ?? null
  let appUuid: string | null = null

  if (options.appId) {
    const { data: app, error } = await capgo
      .from('apps')
      .select('id, owner_org')
      .eq('app_id', options.appId)
      .single()

    if (error)
      throw error

    if (orgId && app?.owner_org && orgId !== app.owner_org) {
      throw new Error('appId does not belong to orgId')
    }

    appUuid = app?.id ?? null
    orgId = orgId ?? app?.owner_org ?? null
  }

  if (!orgId) {
    throw new Error('Cannot create a default API key without an organization')
  }

  const bindings: Array<{
    role_name: string
    scope_type: 'org' | 'app'
    org_id: string
    app_id?: string
  }> = appUuid && !options.orgId
    ? [
        {
          role_name: 'org_member',
          scope_type: 'org',
          org_id: orgId,
        },
        {
          role_name: options.appRole ?? 'app_admin',
          scope_type: 'app',
          org_id: orgId,
          app_id: appUuid,
        },
      ]
    : [
        {
          role_name: 'org_admin',
          scope_type: 'org',
          org_id: orgId,
        },
      ]

  return invokeCapgoApi('apikey', {
    client: capgo,
    method: 'POST',
    body: {
      name,
      hashed: options.hashed === true,
      bindings,
    },
  })
}

export async function createAiApiKey(
  capgo: ConsoleClient<Database>,
  name: string,
  options: {
    /** Organizations used for admin bindings or to validate each selected app's owner. */
    orgIds: string[]
    role: 'admin' | 'member'
    /** Member role only: the apps to grant access on, each with its owning org and chosen app-level role. */
    apps?: Array<{ uuid: string, orgId: string, role: string }>
    /** Admin role only: also grant the `org.create` global permission (create new orgs). */
    allowOrgCreate?: boolean
  },
) {
  const { orgIds, role } = options

  if (!orgIds || orgIds.length === 0) {
    throw new Error('Cannot create an AI API key without an organization')
  }

  const bindings: Array<{
    role_name: string
    scope_type: 'org' | 'app'
    org_id: string
    app_id?: string
  }> = []

  if (role === 'admin') {
    for (const orgId of orgIds)
      bindings.push({ role_name: 'org_admin', scope_type: 'org', org_id: orgId })
  }
  else {
    const orgIdSet = new Set(orgIds)
    const apps = options.apps ?? []
    if (apps.length === 0)
      throw new Error('Select at least one app for a member API key')

    for (const app of apps) {
      if (!orgIdSet.has(app.orgId))
        throw new Error('Each app.orgId must be one of the selected orgIds')
      bindings.push({ role_name: app.role, scope_type: 'app', org_id: app.orgId, app_id: app.uuid })
    }
  }

  // `org.create` is only valid on a key that has an org-admin binding — i.e. the admin role.
  const globalPermissions = role === 'admin' && options.allowOrgCreate ? ['org.create'] : undefined

  return invokeCapgoApi('apikey', {
    client: capgo,
    method: 'POST',
    body: {
      name,
      hashed: false,
      bindings,
      global_permissions: globalPermissions,
    },
  })
}

export async function findUsablePlainApiKey(
  capgo: ConsoleClient<Database>,
  userId: string,
  orgId?: string | null,
  appId?: string | null,
  /**
   * `appRoles`: only reuse an app-level binding with one of these roles (org admin keys still
   * qualify). Without it, any app-level binding on the app counts, even a read-only one.
   */
  options: { name?: string, appRoles?: readonly string[] } = {},
): Promise<string | null> {
  const isLiveKey = (expiresAt: string | null) => !expiresAt || new Date(expiresAt).getTime() > Date.now()
  let appUuid: string | null = null

  let query = capgo
    .from('apikeys')
    .select('key, expires_at, rbac_id, created_at')
    .eq('user_id', userId)
    .not('key', 'is', null)
  if (options.name)
    query = query.eq('name', options.name)
  const { data: keys, error } = await query.order('created_at', { ascending: false })

  if (error || !keys?.length)
    return null

  const liveKeys = keys.filter(key => key.key && isLiveKey(key.expires_at))
  if (!liveKeys.length)
    return null

  if (!orgId)
    return liveKeys[0].key ?? null

  if (appId) {
    const { data: app, error: appError } = await capgo
      .from('apps')
      .select('id, owner_org')
      .eq('app_id', appId)
      .single()

    if (appError || !app?.id || !app.owner_org)
      return null

    if (app.owner_org !== orgId)
      return null

    appUuid = app.id
  }

  const rbacIds = liveKeys.map(key => key.rbac_id).filter((rbacId): rbacId is string => !!rbacId)
  if (!rbacIds.length)
    return null

  const { data: bindings, error: bindingsError } = await capgo
    .from('role_bindings')
    .select('principal_id, scope_type, app_id, roles(name)')
    .eq('principal_type', 'apikey')
    .eq('org_id', orgId)
    .in('principal_id', rbacIds)

  if (bindingsError || !bindings?.length)
    return null

  const orgAdminRoles = new Set(['org_super_admin', 'org_admin'])
  type BindingRole = { name?: string | null } | { name?: string | null }[] | null
  interface BindingRow {
    principal_id: string | null
    scope_type: string | null
    app_id: string | null
    roles: BindingRole
  }
  const scopedKeyIds = new Set((bindings as BindingRow[])
    .filter((binding) => {
      const roleName = Array.isArray(binding.roles) ? binding.roles[0]?.name : binding.roles?.name
      if (!roleName)
        return false
      if (binding.scope_type === 'org' && orgAdminRoles.has(roleName))
        return true
      if (!appUuid || binding.scope_type !== 'app' || binding.app_id !== appUuid)
        return false
      return !options.appRoles || options.appRoles.includes(roleName)
    })
    .map(binding => binding.principal_id)
    .filter((principalId): principalId is string => typeof principalId === 'string'))

  if (!scopedKeyIds.size)
    return null

  return liveKeys.find(key => scopedKeyIds.has(key.rbac_id))?.key ?? null
}

export interface ApiKeyListFilterOptions<T> {
  searchQuery: string
  orgFilterIds: string[]
  appFilterIds: string[]
  getOrgIds: (row: T) => string[]
  getAppIds: (row: T) => string[]
  getSearchableValues: (row: T) => Array<string | null | undefined>
}

export interface ApiKeyListFilterResult<T> {
  rows: T[]
  hiddenByScopeCount: number
}

export function clearApiKeyScopeFilters(filters: Record<string, boolean>): Record<string, boolean> {
  return Object.fromEntries(Object.keys(filters).map(key => [key, false]))
}

export function filterApiKeyListRows<T>(
  rows: T[],
  options: ApiKeyListFilterOptions<T>,
): ApiKeyListFilterResult<T> {
  const query = options.searchQuery.toLowerCase()
  const searchableRows = query
    ? rows.filter(row => options.getSearchableValues(row)
        .some(value => value?.toLowerCase().includes(query) ?? false))
    : [...rows]

  let scopedRows = searchableRows
  if (options.orgFilterIds.length > 0) {
    scopedRows = scopedRows.filter(row => options.orgFilterIds
      .some(orgId => options.getOrgIds(row).includes(orgId)))
  }
  if (options.appFilterIds.length > 0) {
    scopedRows = scopedRows.filter(row => options.appFilterIds
      .some(appId => options.getAppIds(row).includes(appId)))
  }

  const hasScopeFilter = options.orgFilterIds.length > 0 || options.appFilterIds.length > 0
  return {
    rows: scopedRows,
    hiddenByScopeCount: hasScopeFilter ? searchableRows.length - scopedRows.length : 0,
  }
}

interface ApiKeyListRow {
  name?: string | null
  created_at: string | null
}

interface DialogStoreLike {
  lastButtonRole?: string
  openDialog: (options: DialogV2Options) => void
  onDialogDismiss: () => Promise<boolean>
}

type Translate = (key: string) => string

function createDialogButton(
  text: string,
  role: DialogV2Button['role'],
  handler?: DialogV2Button['handler'],
): DialogV2Button {
  return {
    text,
    role,
    handler,
  }
}

export function isApiKeyExpired(expiresAt: string | null): boolean {
  if (!expiresAt)
    return false

  return new Date(expiresAt) < new Date()
}

export function sortApiKeyRows<T extends ApiKeyListRow>(
  rows: T[],
  columns: TableColumn[],
): T[] {
  let result = [...rows]

  columns.forEach((col) => {
    if (!col.sortable || typeof col.sortable !== 'string')
      return

    result = [...result].sort((a, b) => {
      let aValue: string | number = ''
      let bValue: string | number = ''

      switch (col.key) {
        case 'name':
          aValue = a.name?.toLowerCase() || ''
          bValue = b.name?.toLowerCase() || ''
          break
        case 'created_at':
          aValue = a.created_at ? new Date(a.created_at).getTime() : 0
          bValue = b.created_at ? new Date(b.created_at).getTime() : 0
          break
        default:
          return 0
      }

      if (aValue < bValue)
        return col.sortable === 'asc' ? -1 : 1
      if (aValue > bValue)
        return col.sortable === 'asc' ? 1 : -1
      return 0
    })
  })

  return result
}

export async function confirmApiKeyDeletion(
  dialogStore: DialogStoreLike,
  t: Translate,
): Promise<boolean> {
  dialogStore.openDialog({
    title: t('alert-confirm-delete'),
    description: t('alert-delete-api-key-confirm'),
    buttons: [
      createDialogButton(t('button-cancel'), 'cancel'),
      createDialogButton(t('button-delete'), 'danger'),
    ],
  })

  const wasCanceled = await dialogStore.onDialogDismiss()
  return !wasCanceled && dialogStore.lastButtonRole === 'danger'
}

export async function confirmApiKeyRegeneration(
  dialogStore: DialogStoreLike,
  t: Translate,
): Promise<boolean> {
  dialogStore.openDialog({
    title: t('alert-confirm-regenerate'),
    description: t('alert-regenerate-key-confirm'),
    buttons: [
      createDialogButton(t('button-cancel'), 'cancel'),
      createDialogButton(t('button-regenerate'), 'primary'),
    ],
  })

  const wasCanceled = await dialogStore.onDialogDismiss()
  return !wasCanceled && dialogStore.lastButtonRole === 'primary'
}

export async function showApiKeySecretModal(
  dialogStore: DialogStoreLike,
  t: Translate,
  plainKey: string,
  onCopySuccess?: () => void,
): Promise<void> {
  dialogStore.openDialog({
    title: t('secure-key-created'),
    description: `${t('secure-key-warning')}\n\n${t('your-api-key')}: ${plainKey}`,
    size: 'lg',
    buttons: [
      createDialogButton(t('copy-and-close'), 'primary', async () => {
        try {
          await navigator.clipboard.writeText(plainKey)
          onCopySuccess?.()
        }
        catch {}
      }),
    ],
  })

  await dialogStore.onDialogDismiss()
}