src/services/apikeys.ts
Rama main · réplica de lectura del repositorio original.
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()
}