chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
/**
* Tests for API key authentication utilities.
*
* Tests cover:
* - API key format detection (legacy vs encrypted)
* - Authentication against stored keys
* - Key encryption and decryption
* - Display formatting
* - Edge cases
*/
import { randomBytes } from 'crypto'
import { createEncryptedApiKey, createLegacyApiKey } from '@sim/testing'
import { describe, expect, it, vi } from 'vitest'
const cryptoMock = vi.hoisted(() => ({
isEncryptedApiKeyFormat: (key: string) => key.startsWith('sk-sim-'),
isLegacyApiKeyFormat: (key: string) => key.startsWith('sim_') && !key.startsWith('sk-sim-'),
generateApiKey: () => `sim_${randomBytes(24).toString('base64url')}`,
generateEncryptedApiKey: () => `sk-sim-${randomBytes(24).toString('base64url')}`,
encryptApiKey: async (apiKey: string) => ({
encrypted: `mock-iv:${Buffer.from(apiKey).toString('hex')}:mock-tag`,
iv: 'mock-iv',
}),
decryptApiKey: async (encryptedValue: string) => {
if (!encryptedValue.includes(':') || encryptedValue.split(':').length !== 3) {
return { decrypted: encryptedValue }
}
const parts = encryptedValue.split(':')
const hexPart = parts[1]
return { decrypted: Buffer.from(hexPart, 'hex').toString('utf8') }
},
}))
vi.mock('@/lib/api-key/crypto', () => cryptoMock)
import {
formatApiKeyForDisplay,
getApiKeyLast4,
isEncryptedKey,
isValidApiKeyFormat,
} from '@/lib/api-key/auth'
const { generateApiKey, generateEncryptedApiKey, isEncryptedApiKeyFormat, isLegacyApiKeyFormat } =
cryptoMock
describe('isEncryptedKey', () => {
it('should detect encrypted storage format (iv:encrypted:authTag)', () => {
const encryptedStorage = 'abc123:encrypted-data:tag456'
expect(isEncryptedKey(encryptedStorage)).toBe(true)
})
it('should detect plain text storage (no colons)', () => {
const plainKey = 'sim_abcdef123456'
expect(isEncryptedKey(plainKey)).toBe(false)
})
it('should detect plain text with single colon', () => {
const singleColon = 'part1:part2'
expect(isEncryptedKey(singleColon)).toBe(false)
})
it('should detect encrypted format with exactly 3 parts', () => {
const threeParts = 'iv:data:tag'
expect(isEncryptedKey(threeParts)).toBe(true)
})
it('should reject format with more than 3 parts', () => {
const fourParts = 'a:b:c:d'
expect(isEncryptedKey(fourParts)).toBe(false)
})
it('should reject empty string', () => {
expect(isEncryptedKey('')).toBe(false)
})
})
describe('isEncryptedApiKeyFormat (key prefix)', () => {
it('should detect sk-sim- prefix as encrypted format', () => {
const { key } = createEncryptedApiKey()
expect(isEncryptedApiKeyFormat(key)).toBe(true)
})
it('should not detect sim_ prefix as encrypted format', () => {
const { key } = createLegacyApiKey()
expect(isEncryptedApiKeyFormat(key)).toBe(false)
})
it('should not detect random string as encrypted format', () => {
expect(isEncryptedApiKeyFormat('random-string')).toBe(false)
})
})
describe('isLegacyApiKeyFormat', () => {
it('should detect sim_ prefix as legacy format', () => {
const { key } = createLegacyApiKey()
expect(isLegacyApiKeyFormat(key)).toBe(true)
})
it('should not detect sk-sim- prefix as legacy format', () => {
const { key } = createEncryptedApiKey()
expect(isLegacyApiKeyFormat(key)).toBe(false)
})
it('should not detect random string as legacy format', () => {
expect(isLegacyApiKeyFormat('random-string')).toBe(false)
})
})
describe('isValidApiKeyFormat', () => {
it('should accept valid length keys', () => {
expect(isValidApiKeyFormat(`sim_${'a'.repeat(20)}`)).toBe(true)
})
it('should reject too short keys', () => {
expect(isValidApiKeyFormat('short')).toBe(false)
})
it('should reject too long keys (>200 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(201))).toBe(false)
})
it('should accept keys at boundary (11 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(11))).toBe(true)
})
it('should reject keys at boundary (10 chars)', () => {
expect(isValidApiKeyFormat('a'.repeat(10))).toBe(false)
})
it('should reject non-string input', () => {
expect(isValidApiKeyFormat(null as any)).toBe(false)
expect(isValidApiKeyFormat(undefined as any)).toBe(false)
expect(isValidApiKeyFormat(123 as any)).toBe(false)
})
it('should reject empty string', () => {
expect(isValidApiKeyFormat('')).toBe(false)
})
})
describe('getApiKeyLast4', () => {
it('should return last 4 characters of key', () => {
expect(getApiKeyLast4('sim_abcdefghijklmnop')).toBe('mnop')
})
it('should return last 4 characters of encrypted format key', () => {
expect(getApiKeyLast4('sk-sim-abcdefghijkl')).toBe('ijkl')
})
it('should return entire key if less than 4 chars', () => {
expect(getApiKeyLast4('abc')).toBe('abc')
})
it('should handle exactly 4 chars', () => {
expect(getApiKeyLast4('abcd')).toBe('abcd')
})
})
describe('formatApiKeyForDisplay', () => {
it('should format encrypted format key with sk-sim- prefix', () => {
const key = 'sk-sim-abcdefghijklmnopqrstuvwx'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('sk-sim-...uvwx')
})
it('should format legacy key with sim_ prefix', () => {
const key = 'sim_abcdefghijklmnopqrstuvwx'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('sim_...uvwx')
})
it('should format unknown format key with just ellipsis', () => {
const key = 'custom-key-format-abcd'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toBe('...abcd')
})
it('should show last 4 characters correctly', () => {
const key = 'sk-sim-xxxxxxxxxxxxxxxxr6AA'
const formatted = formatApiKeyForDisplay(key)
expect(formatted).toContain('r6AA')
})
})
describe('generateApiKey', () => {
it('should generate key with sim_ prefix', () => {
const key = generateApiKey()
expect(key).toMatch(/^sim_/)
})
it('should generate unique keys', () => {
const key1 = generateApiKey()
const key2 = generateApiKey()
expect(key1).not.toBe(key2)
})
it('should generate key of valid length', () => {
const key = generateApiKey()
expect(key.length).toBeGreaterThan(10)
expect(key.length).toBeLessThan(100)
})
})
describe('generateEncryptedApiKey', () => {
it('should generate key with sk-sim- prefix', () => {
const key = generateEncryptedApiKey()
expect(key).toMatch(/^sk-sim-/)
})
it('should generate unique keys', () => {
const key1 = generateEncryptedApiKey()
const key2 = generateEncryptedApiKey()
expect(key1).not.toBe(key2)
})
it('should generate key of valid length', () => {
const key = generateEncryptedApiKey()
expect(key.length).toBeGreaterThan(10)
expect(key.length).toBeLessThan(100)
})
})
+212
View File
@@ -0,0 +1,212 @@
import { db } from '@sim/db'
import { apiKey } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
import { and, eq } from 'drizzle-orm'
import {
decryptApiKey,
encryptApiKey,
generateApiKey,
generateEncryptedApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ApiKeyAuth')
/**
* API key authentication utilities supporting both legacy plain text keys
* and modern encrypted keys for gradual migration without breaking existing keys
*/
/**
* Checks if a stored key is in the new encrypted format
* @param storedKey - The key stored in the database
* @returns true if the key is encrypted, false if it's plain text
*/
export function isEncryptedKey(storedKey: string): boolean {
// Check if it follows the encrypted format: iv:encrypted:authTag
return storedKey.includes(':') && storedKey.split(':').length === 3
}
/**
* Encrypts an API key for secure storage
* @param apiKey - The plain text API key to encrypt
* @returns Promise<string> - The encrypted key
*/
async function encryptApiKeyForStorage(apiKey: string): Promise<string> {
try {
const { encrypted } = await encryptApiKey(apiKey)
return encrypted
} catch (error) {
logger.error('API key encryption error:', { error })
throw new Error('Failed to encrypt API key')
}
}
/**
* Creates a new API key
* @param useStorage - Whether to encrypt the key before storage (default: true)
* @returns Promise<{key: string, encryptedKey?: string}> - The plain key and optionally encrypted version
*/
export async function createApiKey(useStorage = true): Promise<{
key: string
encryptedKey?: string
}> {
try {
const hasEncryptionKey = env.API_ENCRYPTION_KEY !== undefined
const plainKey = hasEncryptionKey ? generateEncryptedApiKey() : generateApiKey()
if (useStorage) {
const encryptedKey = await encryptApiKeyForStorage(plainKey)
return { key: plainKey, encryptedKey }
}
return { key: plainKey }
} catch (error) {
logger.error('API key creation error:', { error })
throw new Error('Failed to create API key')
}
}
/**
* Decrypts an API key from storage for display purposes
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The decrypted API key
*/
async function decryptApiKeyFromStorage(encryptedKey: string): Promise<string> {
try {
const { decrypted } = await decryptApiKey(encryptedKey)
return decrypted
} catch (error) {
logger.error('API key decryption error:', { error })
throw new Error('Failed to decrypt API key')
}
}
/**
* Gets the last 4 characters of an API key for display purposes
* @param apiKey - The API key (plain text)
* @returns string - The last 4 characters
*/
export function getApiKeyLast4(apiKey: string): string {
return apiKey.slice(-4)
}
/**
* Gets the display format for an API key showing prefix and last 4 characters
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The display format like "sk-sim-...r6AA"
*/
export async function getApiKeyDisplayFormat(encryptedKey: string): Promise<string> {
try {
if (isEncryptedKey(encryptedKey)) {
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
return formatApiKeyForDisplay(decryptedKey)
}
// For plain text keys (legacy), format directly
return formatApiKeyForDisplay(encryptedKey)
} catch (error) {
logger.error('Failed to format API key for display:', { error })
return '****'
}
}
/**
* Formats an API key for display showing prefix and last 4 characters
* @param apiKey - The API key (plain text)
* @returns string - The display format like "sk-sim-...r6AA" or "sim_...r6AA"
*/
export function formatApiKeyForDisplay(apiKey: string): string {
if (isEncryptedApiKeyFormat(apiKey)) {
// For sk-sim- format: "sk-sim-...r6AA"
const last4 = getApiKeyLast4(apiKey)
return `sk-sim-...${last4}`
}
if (isLegacyApiKeyFormat(apiKey)) {
// For sim_ format: "sim_...r6AA"
const last4 = getApiKeyLast4(apiKey)
return `sim_...${last4}`
}
// Unknown format, just show last 4
const last4 = getApiKeyLast4(apiKey)
return `...${last4}`
}
/**
* Gets the last 4 characters of an encrypted API key by decrypting it first
* @param encryptedKey - The encrypted API key from the database
* @returns Promise<string> - The last 4 characters
*/
async function getEncryptedApiKeyLast4(encryptedKey: string): Promise<string> {
try {
if (isEncryptedKey(encryptedKey)) {
const decryptedKey = await decryptApiKeyFromStorage(encryptedKey)
return getApiKeyLast4(decryptedKey)
}
// For plain text keys (legacy), return last 4 directly
return getApiKeyLast4(encryptedKey)
} catch (error) {
logger.error('Failed to get last 4 characters of API key:', { error })
return '****'
}
}
/**
* Validates API key format (basic validation)
* @param apiKey - The API key to validate
* @returns boolean - true if the format appears valid
*/
export function isValidApiKeyFormat(apiKeyValue: string): boolean {
return typeof apiKeyValue === 'string' && apiKeyValue.length > 10 && apiKeyValue.length < 200
}
export async function createWorkspaceApiKey(params: {
workspaceId: string
userId: string
name: string
}) {
const existingKey = await db
.select({ id: apiKey.id })
.from(apiKey)
.where(
and(
eq(apiKey.workspaceId, params.workspaceId),
eq(apiKey.name, params.name),
eq(apiKey.type, 'workspace')
)
)
.limit(1)
if (existingKey.length > 0) {
throw new Error(
`A workspace API key named "${params.name}" already exists. Choose a different name.`
)
}
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
throw new Error('Failed to encrypt API key for storage')
}
const [newKey] = await db
.insert(apiKey)
.values({
id: generateShortId(),
workspaceId: params.workspaceId,
userId: params.userId,
createdBy: params.userId,
name: params.name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'workspace',
createdAt: new Date(),
updatedAt: new Date(),
})
.returning({ id: apiKey.id, name: apiKey.name, createdAt: apiKey.createdAt })
return { id: newKey.id, name: newKey.name, key: plainKey, createdAt: newKey.createdAt }
}
+162
View File
@@ -0,0 +1,162 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockOrderBy, mockDecryptSecret } = vi.hoisted(() => ({
mockOrderBy: vi.fn(),
mockDecryptSecret: vi.fn(),
}))
vi.mock('@sim/db', () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({ orderBy: mockOrderBy })),
})),
})),
},
}))
vi.mock('@/lib/core/security/encryption', () => ({
decryptSecret: mockDecryptSecret,
}))
vi.mock('@/lib/core/config/api-keys', () => ({
getRotatingApiKey: vi.fn(),
}))
vi.mock('@/lib/core/config/env', () => ({
env: {},
}))
vi.mock('@/lib/core/config/env-flags', () => ({
isHosted: false,
}))
vi.mock('@/providers/models', () => ({
getProviderFileAttachment: vi
.fn()
.mockReturnValue({ maxBytes: 10 * 1024 * 1024, strategy: 'inline' }),
INLINE_ATTACHMENT_MAX_BYTES: 10 * 1024 * 1024,
getHostedModels: vi.fn(() => []),
}))
vi.mock('@/providers/utils', () => ({
PROVIDER_PLACEHOLDER_KEY: 'placeholder',
}))
vi.mock('@/stores/providers/store', () => ({
useProvidersStore: { getState: vi.fn() },
}))
import { getBYOKKey } from '@/lib/api-key/byok'
/**
* Rotation counters in the module under test are keyed by
* `${workspaceId}:${providerId}` and persist for the process lifetime, so
* each test uses a unique workspace id to start from a fresh cursor.
*/
let testIndex = 0
const uniqueWorkspaceId = () => `workspace-${++testIndex}`
const storedKey = (id: string) => ({ id, encryptedApiKey: `encrypted-${id}` })
describe('getBYOKKey', () => {
beforeEach(() => {
vi.clearAllMocks()
mockOrderBy.mockResolvedValue([])
mockDecryptSecret.mockImplementation(async (encrypted: string) => ({
decrypted: encrypted.replace('encrypted-', 'decrypted-'),
}))
})
it('returns null when no workspaceId is provided', async () => {
expect(await getBYOKKey(undefined, 'openai')).toBeNull()
expect(await getBYOKKey(null, 'openai')).toBeNull()
})
it('returns null when the workspace has no keys for the provider', async () => {
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
})
it('returns the same key on every call when only one key is stored', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1')])
for (let call = 0; call < 3; call++) {
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
apiKey: 'decrypted-key-1',
isBYOK: true,
})
}
})
it('round-robins across multiple keys in creation order', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2'), storedKey('key-3')])
const apiKeys = []
for (let call = 0; call < 4; call++) {
const result = await getBYOKKey(workspaceId, 'openai')
apiKeys.push(result?.apiKey)
}
expect(apiKeys).toEqual([
'decrypted-key-1',
'decrypted-key-2',
'decrypted-key-3',
'decrypted-key-1',
])
})
it('reads the key list fresh from the database on every call', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1')])
await getBYOKKey(workspaceId, 'openai')
await getBYOKKey(workspaceId, 'openai')
await getBYOKKey(workspaceId, 'openai')
expect(mockOrderBy).toHaveBeenCalledTimes(3)
})
it('tracks rotation independently per provider within a workspace', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-1')
expect((await getBYOKKey(workspaceId, 'anthropic'))?.apiKey).toBe('decrypted-key-1')
expect((await getBYOKKey(workspaceId, 'openai'))?.apiKey).toBe('decrypted-key-2')
})
it('skips a key that fails to decrypt and returns the next one', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
mockDecryptSecret.mockImplementation(async (encrypted: string) => {
if (encrypted === 'encrypted-key-1') {
throw new Error('corrupt ciphertext')
}
return { decrypted: encrypted.replace('encrypted-', 'decrypted-') }
})
expect(await getBYOKKey(workspaceId, 'openai')).toEqual({
apiKey: 'decrypted-key-2',
isBYOK: true,
})
})
it('returns null when every key fails to decrypt', async () => {
const workspaceId = uniqueWorkspaceId()
mockOrderBy.mockResolvedValue([storedKey('key-1'), storedKey('key-2')])
mockDecryptSecret.mockRejectedValue(new Error('corrupt ciphertext'))
expect(await getBYOKKey(workspaceId, 'openai')).toBeNull()
})
it('returns null when the keys query throws', async () => {
mockOrderBy.mockRejectedValue(new Error('database unavailable'))
expect(await getBYOKKey(uniqueWorkspaceId(), 'openai')).toBeNull()
})
})
+255
View File
@@ -0,0 +1,255 @@
import { db } from '@sim/db'
import { workspaceBYOKKeys } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq } from 'drizzle-orm'
import { getRotatingApiKey } from '@/lib/core/config/api-keys'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/env-flags'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getHostedModels } from '@/providers/models'
import { PROVIDER_PLACEHOLDER_KEY } from '@/providers/utils'
import { useProvidersStore } from '@/stores/providers/store'
import type { BYOKProviderId } from '@/tools/types'
const logger = createLogger('BYOKKeys')
export interface BYOKKeyResult {
apiKey: string
isBYOK: true
}
const rotationCounters = new Map<string, number>()
/**
* Advances the per-process round-robin cursor for a rotation pool and returns
* the next index. Counters are per server instance, which keeps rotation free
* of database writes; aggregate load still spreads evenly across keys.
*/
function nextRotationIndex(poolKey: string, poolSize: number): number {
const cursor = (rotationCounters.get(poolKey) ?? -1) + 1
rotationCounters.set(poolKey, cursor)
return cursor % poolSize
}
/**
* Resolves a workspace BYOK key for a provider. When the workspace has
* multiple keys stored for the provider, requests round-robin across them in
* creation order. A key that fails to decrypt is skipped in favor of the next
* one in the pool.
*
* The key list is read fresh every call (not cached): BYOK is not a hot query,
* and reading fresh keeps revocation immediate across ECS tasks.
*/
export async function getBYOKKey(
workspaceId: string | undefined | null,
providerId: BYOKProviderId
): Promise<BYOKKeyResult | null> {
if (!workspaceId) {
return null
}
try {
const keys = await db
.select({ id: workspaceBYOKKeys.id, encryptedApiKey: workspaceBYOKKeys.encryptedApiKey })
.from(workspaceBYOKKeys)
.where(
and(
eq(workspaceBYOKKeys.workspaceId, workspaceId),
eq(workspaceBYOKKeys.providerId, providerId)
)
)
.orderBy(asc(workspaceBYOKKeys.createdAt), asc(workspaceBYOKKeys.id))
if (!keys.length) {
return null
}
const startIndex = nextRotationIndex(`${workspaceId}:${providerId}`, keys.length)
for (let offset = 0; offset < keys.length; offset++) {
const key = keys[(startIndex + offset) % keys.length]
try {
const { decrypted } = await decryptSecret(key.encryptedApiKey)
return { apiKey: decrypted, isBYOK: true }
} catch (error) {
logger.error('Failed to decrypt BYOK key, skipping', {
workspaceId,
providerId,
keyId: key.id,
error,
})
}
}
return null
} catch (error) {
logger.error('Failed to get BYOK key', { workspaceId, providerId, error })
return null
}
}
export async function getApiKeyWithBYOK(
provider: string,
model: string,
workspaceId: string | undefined | null,
userProvidedKey?: string
): Promise<{ apiKey: string; isBYOK: boolean }> {
const isOllamaModel =
provider === 'ollama' || useProvidersStore.getState().providers.ollama.models.includes(model)
if (isOllamaModel) {
return { apiKey: 'empty', isBYOK: false }
}
const isVllmModel =
provider === 'vllm' || useProvidersStore.getState().providers.vllm.models.includes(model)
if (isVllmModel) {
return { apiKey: userProvidedKey || env.VLLM_API_KEY || 'empty', isBYOK: false }
}
const isLitellmModel =
provider === 'litellm' || useProvidersStore.getState().providers.litellm.models.includes(model)
if (isLitellmModel) {
return { apiKey: userProvidedKey || env.LITELLM_API_KEY || 'empty', isBYOK: false }
}
const isFireworksModel =
provider === 'fireworks' ||
useProvidersStore.getState().providers.fireworks.models.includes(model)
if (isFireworksModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'fireworks')
if (byokResult) {
logger.info('Using BYOK key for Fireworks', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.FIREWORKS_API_KEY) {
return { apiKey: env.FIREWORKS_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Fireworks ${model}`)
}
const isTogetherModel =
provider === 'together' ||
useProvidersStore.getState().providers.together.models.includes(model)
if (isTogetherModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'together')
if (byokResult) {
logger.info('Using BYOK key for Together AI', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.TOGETHER_API_KEY) {
return { apiKey: env.TOGETHER_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Together AI ${model}`)
}
const isBasetenModel =
provider === 'baseten' || useProvidersStore.getState().providers.baseten.models.includes(model)
if (isBasetenModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'baseten')
if (byokResult) {
logger.info('Using BYOK key for Baseten', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
if (env.BASETEN_API_KEY) {
return { apiKey: env.BASETEN_API_KEY, isBYOK: false }
}
throw new Error(`API key is required for Baseten ${model}`)
}
const isOllamaCloudModel =
provider === 'ollama-cloud' ||
useProvidersStore.getState().providers['ollama-cloud'].models.includes(model)
if (isOllamaCloudModel) {
if (workspaceId) {
const byokResult = await getBYOKKey(workspaceId, 'ollama-cloud')
if (byokResult) {
logger.info('Using BYOK key for Ollama Cloud', { model, workspaceId })
return byokResult
}
}
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`API key is required for Ollama Cloud ${model}`)
}
const isBedrockModel = provider === 'bedrock' || model.startsWith('bedrock/')
if (isBedrockModel) {
return { apiKey: PROVIDER_PLACEHOLDER_KEY, isBYOK: false }
}
if (provider === 'azure-openai') {
return { apiKey: userProvidedKey || env.AZURE_OPENAI_API_KEY || '', isBYOK: false }
}
if (provider === 'azure-anthropic') {
return { apiKey: userProvidedKey || env.AZURE_ANTHROPIC_API_KEY || '', isBYOK: false }
}
const isOpenAIModel = provider === 'openai'
const isClaudeModel = provider === 'anthropic'
const isGeminiModel = provider === 'google'
const isMistralModel = provider === 'mistral'
const isZaiModel = provider === 'zai'
const isXaiModel = provider === 'xai'
const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)
if (
isHosted &&
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel || isZaiModel || isXaiModel)
) {
const hostedModels = getHostedModels()
const isModelHosted = hostedModels.some((m) => m.toLowerCase() === model.toLowerCase())
logger.debug('BYOK check', { provider, model, workspaceId, isHosted, isModelHosted })
if (isModelHosted || isMistralModel) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key', { provider, model, workspaceId })
return byokResult
}
logger.debug('No BYOK key found, falling back', { provider, model, workspaceId })
if (isModelHosted) {
try {
const serverKey = getRotatingApiKey(isGeminiModel ? 'gemini' : provider)
return { apiKey: serverKey, isBYOK: false }
} catch (_error) {
if (userProvidedKey) {
return { apiKey: userProvidedKey, isBYOK: false }
}
throw new Error(`No API key available for ${provider} ${model}`)
}
}
}
}
if (!userProvidedKey) {
logger.debug('BYOK not applicable, no user key provided', {
provider,
model,
workspaceId,
isHosted,
})
throw new Error(`API key is required for ${provider} ${model}`)
}
return { apiKey: userProvidedKey, isBYOK: false }
}
+89
View File
@@ -0,0 +1,89 @@
/**
* Tests for the API-key crypto primitives.
*
* `hashApiKey` is the foundation of both the new hash-first authentication
* path and the `backfill-api-key-hash` script — the backfill is idempotent
* precisely because `hashApiKey` is deterministic and the encrypted round-trip
* recovers the same plain-text key on every run.
*
* @vitest-environment node
*/
import { randomBytes } from 'crypto'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnv } = vi.hoisted(() => ({
mockEnv: { API_ENCRYPTION_KEY: undefined as string | undefined },
}))
vi.mock('@/lib/core/config/env', () => ({
env: mockEnv,
}))
import {
decryptApiKey,
encryptApiKey,
hashApiKey,
isEncryptedApiKeyFormat,
isLegacyApiKeyFormat,
} from '@/lib/api-key/crypto'
const FIXED_ENCRYPTION_KEY = '0'.repeat(64)
describe('hashApiKey', () => {
it('is deterministic — same input produces same hash', () => {
const h1 = hashApiKey('sk-sim-example')
const h2 = hashApiKey('sk-sim-example')
expect(h1).toBe(h2)
})
it('produces a 64-char hex SHA-256 digest', () => {
const hash = hashApiKey('sk-sim-example')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('produces different hashes for different inputs', () => {
expect(hashApiKey('sk-sim-a')).not.toBe(hashApiKey('sk-sim-b'))
})
it('matches the published SHA-256 vector for the empty string', () => {
expect(hashApiKey('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
})
})
describe('backfill idempotency — encrypted round-trip', () => {
beforeEach(() => {
mockEnv.API_ENCRYPTION_KEY = FIXED_ENCRYPTION_KEY
})
it('re-running the backfill on the same row yields the same keyHash', async () => {
const plainKey = `sk-sim-${randomBytes(12).toString('hex')}`
const { encrypted } = await encryptApiKey(plainKey)
const { decrypted: first } = await decryptApiKey(encrypted)
const { decrypted: second } = await decryptApiKey(encrypted)
expect(first).toBe(plainKey)
expect(second).toBe(plainKey)
expect(hashApiKey(first)).toBe(hashApiKey(second))
})
it('is stable whether the stored key is legacy plain text or encrypted', async () => {
const plainKey = 'sim_legacy-format-key'
const { encrypted } = await encryptApiKey(plainKey)
const { decrypted } = await decryptApiKey(encrypted)
expect(hashApiKey(decrypted)).toBe(hashApiKey(plainKey))
})
})
describe('api-key format helpers', () => {
it('treats sk-sim- prefix as the encrypted format', () => {
expect(isEncryptedApiKeyFormat('sk-sim-abc')).toBe(true)
expect(isLegacyApiKeyFormat('sk-sim-abc')).toBe(false)
})
it('treats sim_ prefix as the legacy format', () => {
expect(isLegacyApiKeyFormat('sim_abc')).toBe(true)
expect(isEncryptedApiKeyFormat('sim_abc')).toBe(false)
})
})
+106
View File
@@ -0,0 +1,106 @@
import { createLogger } from '@sim/logger'
import { decrypt, encrypt } from '@sim/security/encryption'
import { sha256Hex } from '@sim/security/hash'
import { generateSecureToken } from '@sim/security/tokens'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
const logger = createLogger('ApiKeyCrypto')
function getApiEncryptionKey(): Buffer | null {
const key = env.API_ENCRYPTION_KEY
if (!key) {
logger.warn(
'API_ENCRYPTION_KEY not set - API keys will be stored in plain text. Consider setting this for better security.'
)
return null
}
if (key.length !== 64) {
throw new Error('API_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)')
}
return Buffer.from(key, 'hex')
}
/**
* Encrypts an API key using the dedicated API encryption key. Falls back to
* returning the plain key when `API_ENCRYPTION_KEY` is unset, for backward
* compatibility with deployments that predate encryption-at-rest.
*/
export async function encryptApiKey(apiKey: string): Promise<{ encrypted: string; iv: string }> {
const key = getApiEncryptionKey()
if (!key) {
return { encrypted: apiKey, iv: '' }
}
return encrypt(apiKey, key)
}
/**
* Decrypts an API key previously produced by {@link encryptApiKey}. Values
* that lack the `iv:ciphertext:authTag` shape are assumed to be legacy plain
* text and returned unchanged.
*/
export async function decryptApiKey(encryptedValue: string): Promise<{ decrypted: string }> {
const parts = encryptedValue.split(':')
if (parts.length !== 3) {
return { decrypted: encryptedValue }
}
const key = getApiEncryptionKey()
if (!key) {
return { decrypted: encryptedValue }
}
try {
return await decrypt(encryptedValue, key)
} catch (error) {
logger.error('API key decryption error:', { error: toError(error).message })
throw error
}
}
/**
* Generates a standardized API key with the 'sim_' prefix (legacy format)
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${generateSecureToken(24)}`
}
/**
* Generates a new encrypted API key with the 'sk-sim-' prefix
* @returns A new encrypted API key string
*/
export function generateEncryptedApiKey(): string {
return `sk-sim-${generateSecureToken(24)}`
}
/**
* Determines if an API key uses the new encrypted format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the new encrypted format (sk-sim- prefix)
*/
export function isEncryptedApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sk-sim-')
}
/**
* Determines if an API key uses the legacy format based on prefix
* @param apiKey - The API key to check
* @returns true if the key uses the legacy format (sim_ prefix)
*/
export function isLegacyApiKeyFormat(apiKey: string): boolean {
return apiKey.startsWith('sim_') && !apiKey.startsWith('sk-sim-')
}
/**
* Deterministically hashes a plain-text API key for indexed lookup. The hash
* column has a unique index so authentication can match an incoming key via a
* single `WHERE key_hash = $hash` lookup instead of scanning and decrypting
* every stored encrypted key.
*
* @param plainKey - The plain-text API key as presented by the client
* @returns The hex-encoded SHA-256 digest
*/
export function hashApiKey(plainKey: string): string {
return sha256Hex(plainKey)
}
@@ -0,0 +1,82 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { createWorkspaceApiKey } from '@/lib/api-key/auth'
import { PlatformEvents } from '@/lib/core/telemetry'
const logger = createLogger('ApiKeyOrchestration')
export type ApiKeyOrchestrationErrorCode = 'conflict' | 'internal'
export interface PerformCreateWorkspaceApiKeyParams {
workspaceId: string
userId: string
name: string
source?: string
actorName?: string | null
actorEmail?: string | null
}
export interface PerformCreateWorkspaceApiKeyResult {
success: boolean
error?: string
errorCode?: ApiKeyOrchestrationErrorCode
key?: {
id: string
name: string
key: string
createdAt: Date
}
}
export async function performCreateWorkspaceApiKey(
params: PerformCreateWorkspaceApiKeyParams
): Promise<PerformCreateWorkspaceApiKeyResult> {
try {
const key = await createWorkspaceApiKey({
workspaceId: params.workspaceId,
userId: params.userId,
name: params.name,
})
try {
PlatformEvents.apiKeyGenerated({
userId: params.userId,
keyName: params.name,
})
} catch {}
logger.info('Created workspace API key', {
workspaceId: params.workspaceId,
keyId: key.id,
name: params.name,
})
recordAudit({
workspaceId: params.workspaceId,
actorId: params.userId,
actorName: params.actorName ?? undefined,
actorEmail: params.actorEmail ?? undefined,
action: AuditAction.API_KEY_CREATED,
resourceType: AuditResourceType.API_KEY,
resourceId: key.id,
resourceName: params.name,
description: `Created API key "${params.name}"`,
metadata: {
keyName: params.name,
keyType: 'workspace',
source: params.source ?? 'settings',
},
})
return { success: true, key }
} catch (error) {
const message = toError(error).message
logger.error('Failed to create workspace API key', { error })
return {
success: false,
error: message,
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
}
}
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Tests for authenticateApiKeyFromHeader.
*
* Authentication looks up a single row by the SHA-256 hash of the incoming
* API key and applies the scope / expiry / permission gates. Any miss — no
* matching hash or a failed gate — returns an invalid result.
*
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@sim/db', () => dbChainMock)
const { serviceLogger } = vi.hoisted(() => {
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
withMetadata: vi.fn(),
}
logger.child.mockReturnValue(logger)
logger.withMetadata.mockReturnValue(logger)
return { serviceLogger: logger }
})
vi.mock('@sim/logger', () => ({
createLogger: vi.fn(() => serviceLogger),
logger: serviceLogger,
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
getRequestContext: vi.fn(() => undefined),
}))
const { mockGetWorkspaceBillingSettings } = vi.hoisted(() => ({
mockGetWorkspaceBillingSettings: vi.fn(),
}))
vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBillingSettings: mockGetWorkspaceBillingSettings,
}))
const { mockGetUserEntityPermissions } = vi.hoisted(() => ({
mockGetUserEntityPermissions: vi.fn(),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
import { hashApiKey } from '@/lib/api-key/crypto'
import { authenticateApiKeyFromHeader, updateApiKeyLastUsed } from '@/lib/api-key/service'
function personalKeyRecord(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'key-1',
userId: 'user-1',
workspaceId: null as string | null,
type: 'personal',
expiresAt: null as Date | null,
...overrides,
}
}
describe('authenticateApiKeyFromHeader', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetWorkspaceBillingSettings.mockReset()
mockGetUserEntityPermissions.mockReset()
})
it('returns error when no header is provided', async () => {
const result = await authenticateApiKeyFromHeader('')
expect(result).toEqual({ success: false, error: 'API key required' })
expect(dbChainMockFns.where).not.toHaveBeenCalled()
})
it('resolves when the hash lookup finds a row', async () => {
const record = personalKeyRecord()
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({
success: true,
userId: 'user-1',
keyId: 'key-1',
keyType: 'personal',
workspaceId: undefined,
})
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the hash lookup finds a row that fails scope checks', async () => {
const record = personalKeyRecord({ userId: 'other-user' })
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the key belongs to a banned user', async () => {
const record = personalKeyRecord({ userBanned: true })
dbChainMockFns.where.mockResolvedValueOnce([record])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('returns invalid when the hash lookup finds no row', async () => {
dbChainMockFns.where.mockResolvedValueOnce([])
const result = await authenticateApiKeyFromHeader('sk-sim-plain-key', {
userId: 'user-1',
})
expect(result).toEqual({ success: false, error: 'Invalid API key' })
expect(dbChainMockFns.where).toHaveBeenCalledTimes(1)
})
it('queries by the sha256 hash of the incoming header', async () => {
dbChainMockFns.where.mockResolvedValueOnce([personalKeyRecord()])
await authenticateApiKeyFromHeader('sk-sim-plain-key', { userId: 'user-1' })
const [filter] = dbChainMockFns.where.mock.calls[0]
const expected = hashApiKey('sk-sim-plain-key')
expect(JSON.stringify(filter)).toContain(expected)
})
})
describe('updateApiKeyLastUsed', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('only writes when the stored lastUsed is missing or stale', async () => {
await updateApiKeyLastUsed('key-1')
expect(dbChainMockFns.update).toHaveBeenCalledTimes(1)
expect(dbChainMockFns.set).toHaveBeenCalledWith({ lastUsed: expect.any(Date) })
const [condition] = dbChainMockFns.where.mock.calls[0]
expect(condition).toMatchObject({
type: 'and',
conditions: [
{ type: 'eq', right: 'key-1' },
{ type: 'or', conditions: [{ type: 'isNull' }, { type: 'lt' }] },
],
})
})
it('swallows database errors instead of failing the request', async () => {
dbChainMockFns.update.mockImplementationOnce(() => {
throw new Error('connection lost')
})
await expect(updateApiKeyLastUsed('key-1')).resolves.toBeUndefined()
expect(serviceLogger.error).toHaveBeenCalled()
})
})
+166
View File
@@ -0,0 +1,166 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable, user as userTable } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq, isNull, lt, or } from 'drizzle-orm'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
import { getWorkspaceBillingSettings, type WorkspaceBillingSettings } from '@/lib/workspaces/utils'
const logger = createLogger('ApiKeyService')
export async function listApiKeys(workspaceId: string) {
return db
.select({
id: apiKeyTable.id,
name: apiKeyTable.name,
type: apiKeyTable.type,
lastUsed: apiKeyTable.lastUsed,
createdAt: apiKeyTable.createdAt,
expiresAt: apiKeyTable.expiresAt,
createdBy: apiKeyTable.createdBy,
})
.from(apiKeyTable)
.where(and(eq(apiKeyTable.workspaceId, workspaceId), eq(apiKeyTable.type, 'workspace')))
.orderBy(apiKeyTable.createdAt)
}
export interface ApiKeyAuthOptions {
userId?: string
workspaceId?: string
keyTypes?: ('personal' | 'workspace')[]
}
export interface ApiKeyAuthResult {
success: boolean
userId?: string
keyId?: string
keyType?: 'personal' | 'workspace'
workspaceId?: string
error?: string
}
const INVALID = { success: false, error: 'Invalid API key' } as const
interface HashCandidate {
id: string
userId: string
workspaceId: string | null
type: string
expiresAt: Date | null
userBanned: boolean | null
}
/**
* Authenticate an API key from header with flexible filtering options.
*
* Looks up a single row by `sha256(apiKeyHeader)` and applies the scope /
* expiry / permission gates. Any miss — no matching hash or a failed gate —
* returns `INVALID`.
*/
export async function authenticateApiKeyFromHeader(
apiKeyHeader: string,
options: ApiKeyAuthOptions = {}
): Promise<ApiKeyAuthResult> {
if (!apiKeyHeader) {
return { success: false, error: 'API key required' }
}
try {
let workspaceSettings: WorkspaceBillingSettings | null = null
if (options.workspaceId) {
workspaceSettings = await getWorkspaceBillingSettings(options.workspaceId)
if (!workspaceSettings) {
return { success: false, error: 'Workspace not found' }
}
}
const keyHash = hashApiKey(apiKeyHeader)
const rows: HashCandidate[] = await db
.select({
id: apiKeyTable.id,
userId: apiKeyTable.userId,
workspaceId: apiKeyTable.workspaceId,
type: apiKeyTable.type,
expiresAt: apiKeyTable.expiresAt,
userBanned: userTable.banned,
})
.from(apiKeyTable)
.leftJoin(userTable, eq(apiKeyTable.userId, userTable.id))
.where(eq(apiKeyTable.keyHash, keyHash))
if (rows.length === 0) return INVALID
const record = rows[0]
const keyType = record.type as 'personal' | 'workspace'
// Defense in depth: banning deletes a user's keys, but reject any survivor too.
if (record.userBanned) return INVALID
if (options.userId && record.userId !== options.userId) return INVALID
if (options.keyTypes?.length && !options.keyTypes.includes(keyType)) return INVALID
if (record.expiresAt && record.expiresAt < new Date()) return INVALID
if (
options.workspaceId &&
keyType === 'workspace' &&
record.workspaceId !== options.workspaceId
) {
return INVALID
}
if (options.workspaceId && keyType === 'personal') {
if (!workspaceSettings?.allowPersonalApiKeys) return INVALID
if (!record.userId) return INVALID
const permission = await getUserEntityPermissions(
record.userId,
'workspace',
options.workspaceId
)
if (permission === null) return INVALID
}
logger.debug('API key matched via hash lookup', { keyId: record.id, keyType })
return {
success: true,
userId: record.userId,
keyId: record.id,
keyType,
workspaceId: record.workspaceId || options.workspaceId || undefined,
}
} catch (error) {
logger.error('API key authentication error:', error)
return { success: false, error: 'Authentication failed' }
}
}
const LAST_USED_STALENESS_WINDOW_MS = 10 * 60 * 1000
/**
* Update the last used timestamp for an API key.
*
* `lastUsed` is display-only, so the write uses a staleness window: it only
* fires when the stored value is older than
* {@link LAST_USED_STALENESS_WINDOW_MS}. High-traffic keys otherwise rewrite
* the same row on every request, serializing concurrent requests behind row
* locks. The 10-minute window matches GitLab's personal-access-token
* last-used tracking.
*/
export async function updateApiKeyLastUsed(keyId: string): Promise<void> {
try {
const staleBefore = new Date(Date.now() - LAST_USED_STALENESS_WINDOW_MS)
await db
.update(apiKeyTable)
.set({ lastUsed: new Date() })
.where(
and(
eq(apiKeyTable.id, keyId),
or(isNull(apiKeyTable.lastUsed), lt(apiKeyTable.lastUsed, staleBefore))
)
)
} catch (error) {
logger.error('Error updating API key last used:', error)
}
}