improvement(keys): make keys account-wide instead of issuing a new one every time a workflow is created (#192)

* improvement(keys): make keys acconut-wide instead of issuing a new one every time a workflow is created

* removed old api key validation & issue logic

* standardized api key format & added tests

* improvement(api): deprecated /db from /api and moved all routes to their relevant domain location. updated all references to old routes (#191)
This commit is contained in:
Waleed Latif
2025-03-26 15:59:54 -07:00
committed by GitHub
parent 566af68d77
commit fe2b64050a
12 changed files with 1889 additions and 45 deletions

View File

@@ -3,8 +3,9 @@ import { eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { getSession } from '@/lib/auth'
import { createLogger } from '@/lib/logs/console-logger'
import { apiKey } from '@/db/schema'
import { generateApiKey } from '@/lib/utils'
import { db } from '@/db'
import { apiKey } from '@/db/schema'
const logger = createLogger('ApiKeysRoute')
@@ -61,8 +62,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid request. Name is required.' }, { status: 400 })
}
// Generate an API key - we'll use a prefix to identify this as an API key (sim_)
const keyValue = `sim_${nanoid(32)}`
const keyValue = generateApiKey()
// Insert the new API key
const [newKey] = await db

View File

@@ -0,0 +1,374 @@
/**
* Integration tests for workflow deployment API route
*
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMockRequest } from '@/app/api/__test-utils__/utils'
describe('Workflow Deployment API Route', () => {
beforeEach(() => {
vi.resetModules()
// Mock utils
vi.doMock('@/lib/utils', () => ({
generateApiKey: vi.fn().mockReturnValue('sim_testkeygenerated12345'),
}))
// Mock UUID generation
vi.doMock('uuid', () => ({
v4: vi.fn().mockReturnValue('mock-uuid-1234'),
}))
// Mock crypto for request ID
vi.stubGlobal('crypto', {
randomUUID: vi.fn().mockReturnValue('mock-request-id'),
})
// Mock logger
vi.doMock('@/lib/logs/console-logger', () => ({
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
}))
// Mock the middleware to pass validation
vi.doMock('../../middleware', () => ({
validateWorkflowAccess: vi.fn().mockResolvedValue({
workflow: {
id: 'workflow-id',
userId: 'user-id',
},
}),
}))
// Mock the response utils
vi.doMock('../../utils', () => ({
createSuccessResponse: vi.fn().mockImplementation((data) => {
return new Response(JSON.stringify(data), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}),
createErrorResponse: vi.fn().mockImplementation((message, status = 500) => {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { 'Content-Type': 'application/json' },
})
}),
}))
})
afterEach(() => {
vi.clearAllMocks()
})
/**
* Test GET deployment status
*/
it('should fetch deployment info successfully', async () => {
// Mock the database with proper workflow data
vi.doMock('@/db', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
isDeployed: false,
deployedAt: null,
userId: 'user-id',
},
]),
}),
}),
}),
},
}))
// Create a mock request
const req = createMockRequest('GET')
// Create params similar to what Next.js would provide
const params = Promise.resolve({ id: 'workflow-id' })
// Import the handler after mocks are set up
const { GET } = await import('./route')
// Call the handler
const response = await GET(req, { params })
// Check response
expect(response.status).toBe(200)
// Parse the response body
const data = await response.json()
// Verify response structure
expect(data).toHaveProperty('isDeployed', false)
expect(data).toHaveProperty('apiKey', null)
expect(data).toHaveProperty('deployedAt', null)
})
/**
* Test POST deployment with no existing API key
* This should generate a new API key
*/
it('should create new API key when deploying workflow for user with no API key', async () => {
// Mock DB for this test
const mockInsert = vi.fn().mockReturnValue({
values: vi.fn().mockReturnValue(undefined),
})
const mockUpdate = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]),
}),
})
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
userId: 'user-id',
},
]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing API key
}),
}),
}),
insert: mockInsert,
update: mockUpdate,
},
}))
// Create a mock request
const req = createMockRequest('POST')
// Create params
const params = Promise.resolve({ id: 'workflow-id' })
// Import required modules after mocks are set up
const { POST } = await import('./route')
// Call the handler
const response = await POST(req, { params })
// Check response
expect(response.status).toBe(200)
// Parse the response body
const data = await response.json()
// Verify API key was generated
expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345')
expect(data).toHaveProperty('isDeployed', true)
expect(data).toHaveProperty('deployedAt')
// Verify database calls
expect(mockInsert).toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
})
/**
* Test POST deployment with existing API key
* This should use the existing API key
*/
it('should use existing API key when deploying workflow', async () => {
// Mock DB for this test
const mockInsert = vi.fn()
const mockUpdate = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]),
}),
})
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
userId: 'user-id',
},
]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
key: 'sim_existingtestapikey12345',
},
]), // Existing API key
}),
}),
}),
insert: mockInsert,
update: mockUpdate,
},
}))
// Create a mock request
const req = createMockRequest('POST')
// Create params
const params = Promise.resolve({ id: 'workflow-id' })
// Import required modules after mocks are set up
const { POST } = await import('./route')
// Call the handler
const response = await POST(req, { params })
// Check response
expect(response.status).toBe(200)
// Parse the response body
const data = await response.json()
// Verify existing API key was used
expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345')
expect(data).toHaveProperty('isDeployed', true)
// Verify database calls - should NOT have inserted a new API key
expect(mockInsert).not.toHaveBeenCalled()
expect(mockUpdate).toHaveBeenCalled()
})
/**
* Test DELETE undeployment
*/
it('should undeploy workflow successfully', async () => {
// Mock the DB for this test
const mockUpdate = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([{ id: 'workflow-id' }]),
}),
})
vi.doMock('@/db', () => ({
db: {
update: mockUpdate,
},
}))
// Create a mock request
const req = createMockRequest('DELETE')
// Create params
const params = Promise.resolve({ id: 'workflow-id' })
// Import the handler after mocks are set up
const { DELETE } = await import('./route')
// Call the handler
const response = await DELETE(req, { params })
// Check response
expect(response.status).toBe(200)
// Parse the response body
const data = await response.json()
// Verify response structure
expect(data).toHaveProperty('isDeployed', false)
expect(data).toHaveProperty('deployedAt', null)
expect(data).toHaveProperty('apiKey', null)
// Verify database calls
expect(mockUpdate).toHaveBeenCalled()
})
/**
* Test error handling
*/
it('should handle errors when workflow is not found', async () => {
// Mock middleware to simulate an error
vi.doMock('../../middleware', () => ({
validateWorkflowAccess: vi.fn().mockResolvedValue({
error: {
message: 'Workflow not found',
status: 404,
},
}),
}))
// Create a mock request
const req = createMockRequest('POST')
// Create params with an invalid ID
const params = Promise.resolve({ id: 'invalid-id' })
// Import the handler after mocks are set up
const { POST } = await import('./route')
// Call the handler
const response = await POST(req, { params })
// Check response
expect(response.status).toBe(404)
// Parse the response body
const data = await response.json()
// Verify error message
expect(data).toHaveProperty('error', 'Workflow not found')
})
/**
* Test unauthorized access
*/
it('should handle unauthorized access to workflow', async () => {
// Mock middleware to simulate unauthorized access
vi.doMock('../../middleware', () => ({
validateWorkflowAccess: vi.fn().mockResolvedValue({
error: {
message: 'Unauthorized access',
status: 403,
},
}),
}))
// Create a mock request
const req = createMockRequest('POST')
// Create params
const params = Promise.resolve({ id: 'workflow-id' })
// Import the handler after mocks are set up
const { POST } = await import('./route')
// Call the handler
const response = await POST(req, { params })
// Check response
expect(response.status).toBe(403)
// Parse the response body
const data = await response.json()
// Verify error message
expect(data).toHaveProperty('error', 'Unauthorized access')
})
})

View File

@@ -2,8 +2,9 @@ import { NextRequest } from 'next/server'
import { eq } from 'drizzle-orm'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console-logger'
import { generateApiKey } from '@/lib/utils'
import { db } from '@/db'
import { workflow } from '@/db/schema'
import { apiKey, workflow } from '@/db/schema'
import { validateWorkflowAccess } from '../../middleware'
import { createErrorResponse, createSuccessResponse } from '../../utils'
@@ -28,9 +29,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
// Fetch the workflow information including deployment details
const result = await db
.select({
apiKey: workflow.apiKey,
isDeployed: workflow.isDeployed,
deployedAt: workflow.deployedAt,
userId: workflow.userId,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -44,18 +45,27 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
const workflowData = result[0]
// If the workflow is not deployed, return appropriate response
if (!workflowData.isDeployed || !workflowData.apiKey) {
if (!workflowData.isDeployed) {
logger.info(`[${requestId}] Workflow is not deployed: ${id}`)
return createSuccessResponse({
isDeployed: false,
apiKey: null,
deployedAt: null,
apiKey: null,
})
}
// Fetch the user's API key
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.limit(1)
logger.info(`[${requestId}] Successfully retrieved deployment info: ${id}`)
return createSuccessResponse({
apiKey: workflowData.apiKey,
apiKey: userApiKey.length > 0 ? userApiKey[0].key : null,
isDeployed: workflowData.isDeployed,
deployedAt: workflowData.deployedAt,
})
@@ -78,22 +88,61 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Generate a new API key
const apiKey = `wf_${uuidv4().replace(/-/g, '')}`
// Get the workflow to find the user
const workflowData = await db
.select({
userId: workflow.userId,
})
.from(workflow)
.where(eq(workflow.id, id))
.limit(1)
if (workflowData.length === 0) {
logger.warn(`[${requestId}] Workflow not found: ${id}`)
return createErrorResponse('Workflow not found', 404)
}
const userId = workflowData[0].userId
const deployedAt = new Date()
// Update the workflow with the API key and deployment status
// Check if the user already has an API key
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, userId))
.limit(1)
let userKey = null
// If no API key exists, create one
if (userApiKey.length === 0) {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId,
name: 'Default API Key',
key: newApiKey,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKey
} else {
userKey = userApiKey[0].key
}
// Update the workflow deployment status
await db
.update(workflow)
.set({
apiKey,
isDeployed: true,
deployedAt,
})
.where(eq(workflow.id, id))
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
return createSuccessResponse({ apiKey, isDeployed: true, deployedAt })
return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt })
} catch (error: any) {
logger.error(`[${requestId}] Error deploying workflow: ${id}`, error)
return createErrorResponse(error.message || 'Failed to deploy workflow', 500)
@@ -116,11 +165,10 @@ export async function DELETE(
return createErrorResponse(validation.error.message, validation.error.status)
}
// Update the workflow to remove deployment
// Update the workflow to remove deployment status
await db
.update(workflow)
.set({
apiKey: null,
isDeployed: false,
deployedAt: null,
})

View File

@@ -1,6 +1,9 @@
import { NextRequest } from 'next/server'
import { eq } from 'drizzle-orm'
import { createLogger } from '@/lib/logs/console-logger'
import { getWorkflowById } from '@/lib/workflows/utils'
import { db } from '@/db'
import { apiKey } from '@/db/schema'
const logger = createLogger('WorkflowMiddleware')
@@ -36,18 +39,37 @@ export async function validateWorkflowAccess(
}
// API key authentication
let apiKey = null
let apiKeyHeader = null
for (const [key, value] of request.headers.entries()) {
if (key.toLowerCase() === 'x-api-key' && value) {
apiKey = value
apiKeyHeader = value
break
}
}
if (!apiKey || !workflow.apiKey || apiKey !== workflow.apiKey) {
if (!apiKeyHeader) {
return {
error: {
message: 'Unauthorized',
message: 'Unauthorized: API key required',
status: 401,
},
}
}
// Verify API key belongs to the user who owns the workflow
const userApiKeys = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflow.userId))
const validApiKey = userApiKeys.some((k) => k.key === apiKeyHeader)
if (!validApiKey) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}

View File

@@ -211,10 +211,10 @@ export function ControlBar() {
const handleDeploy = async () => {
if (!activeWorkflowId) return
// If already deployed, show the existing deployment info instead of redeploying
// If already deployed, show the API info
if (isDeployed) {
// Find existing API notification for this workflow
const apiNotification = workflowNotifications.find(
// Try to find an existing API notification
const apiNotification = notifications.find(
(n) => n.type === 'api' && n.workflowId === activeWorkflowId
)
@@ -244,11 +244,13 @@ export function ControlBar() {
},
{
label: 'API Key',
content: apiKey,
content: apiKey || 'No API key found. Visit your account settings to create one.',
},
{
label: 'Example curl command',
content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`,
content: apiKey
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
},
],
})
@@ -285,11 +287,13 @@ export function ControlBar() {
},
{
label: 'API Key',
content: apiKey,
content: apiKey || 'No API key found. Visit your account settings to create one.',
},
{
label: 'Example curl command',
content: `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`,
content: apiKey
? `curl -X POST -H "X-API-Key: ${apiKey}" -H "Content-Type: application/json" ${endpoint}`
: `You need an API key to call this endpoint. Visit your account settings to create one.`,
},
],
})

View File

@@ -170,8 +170,8 @@ export function ApiKeys({ onOpenChange }: ApiKeysProps) {
</div>
<p className="text-sm text-muted-foreground leading-relaxed">
API keys allow you to authenticate with the Sim SDK. Keep your API keys secure. They have
access to your account and workflows.
API keys allow you to authenticate and trigger workflows. Keep your API keys secure. They
have access to your account and workflows.
</p>
{isLoading ? (

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow" DROP COLUMN "api_key";

File diff suppressed because it is too large Load Diff

View File

@@ -162,6 +162,13 @@
"when": 1742889909342,
"tag": "0022_gray_galactus",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1743024111706,
"tag": "0023_nervous_tyger_tiger",
"breakpoints": true
}
]
}
}

View File

@@ -73,7 +73,6 @@ export const workflow = pgTable('workflow', {
updatedAt: timestamp('updated_at').notNull(),
isDeployed: boolean('is_deployed').notNull().default(false),
deployedAt: timestamp('deployed_at'),
apiKey: text('api_key'),
isPublished: boolean('is_published').notNull().default(false),
collaborators: json('collaborators').notNull().default('[]'),
runCount: integer('run_count').notNull().default(0),

199
sim/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
cn,
convertScheduleOptionsToCron,
decryptSecret,
encryptSecret,
formatDate,
formatDateTime,
formatDuration,
formatTime,
generateApiKey,
} from './utils'
// Mock crypto module for encryption/decryption tests
vi.mock('crypto', () => ({
createCipheriv: vi.fn().mockReturnValue({
update: vi.fn().mockReturnValue('encrypted-data'),
final: vi.fn().mockReturnValue('final-data'),
getAuthTag: vi.fn().mockReturnValue({
toString: vi.fn().mockReturnValue('auth-tag'),
}),
}),
createDecipheriv: vi.fn().mockReturnValue({
update: vi.fn().mockReturnValue('decrypted-data'),
final: vi.fn().mockReturnValue('final-data'),
setAuthTag: vi.fn(),
}),
randomBytes: vi.fn().mockReturnValue({
toString: vi.fn().mockReturnValue('random-iv'),
}),
}))
// Mock environment variables for encryption key
beforeEach(() => {
process.env.ENCRYPTION_KEY = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef'
})
afterEach(() => {
vi.clearAllMocks()
})
describe('generateApiKey', () => {
it('should generate API key with sim_ prefix', () => {
const key = generateApiKey()
expect(key).toMatch(/^sim_/)
})
it('should generate unique API keys for each call', () => {
const key1 = generateApiKey()
const key2 = generateApiKey()
expect(key1).not.toBe(key2)
})
it('should generate API keys of correct length', () => {
const key = generateApiKey()
// Expected format: 'sim_' + 32 random characters
expect(key.length).toBe(36)
})
})
describe('cn (class name utility)', () => {
it('should merge class names correctly', () => {
const result = cn('class1', 'class2')
expect(result).toBe('class1 class2')
})
it('should handle conditional classes', () => {
const isActive = true
const result = cn('base', isActive && 'active')
expect(result).toBe('base active')
})
it('should handle falsy values', () => {
const result = cn('base', false && 'hidden', null, undefined, 0, '')
expect(result).toBe('base')
})
it('should handle arrays of class names', () => {
const result = cn('base', ['class1', 'class2'])
expect(result).toContain('base')
expect(result).toContain('class1')
expect(result).toContain('class2')
})
})
describe('encryption and decryption', () => {
it('should encrypt secrets correctly', async () => {
const result = await encryptSecret('my-secret')
expect(result).toHaveProperty('encrypted')
expect(result).toHaveProperty('iv')
expect(result.encrypted).toContain('random-iv')
expect(result.encrypted).toContain('encrypted-data')
expect(result.encrypted).toContain('final-data')
expect(result.encrypted).toContain('auth-tag')
})
it('should decrypt secrets correctly', async () => {
const result = await decryptSecret('iv:encrypted:authTag')
expect(result).toHaveProperty('decrypted')
expect(result.decrypted).toBe('decrypted-datafinal-data')
})
it('should throw error for invalid decrypt format', async () => {
await expect(decryptSecret('invalid-format')).rejects.toThrow('Invalid encrypted value format')
})
})
describe('convertScheduleOptionsToCron', () => {
it('should convert minutes schedule to cron', () => {
const result = convertScheduleOptionsToCron('minutes', { minutesInterval: '5' })
expect(result).toBe('*/5 * * * *')
})
it('should convert hourly schedule to cron', () => {
const result = convertScheduleOptionsToCron('hourly', { hourlyMinute: '30' })
expect(result).toBe('30 * * * *')
})
it('should convert daily schedule to cron', () => {
const result = convertScheduleOptionsToCron('daily', { dailyTime: '15:30' })
expect(result).toBe('15 30 * * *')
})
it('should convert weekly schedule to cron', () => {
const result = convertScheduleOptionsToCron('weekly', {
weeklyDay: 'MON',
weeklyDayTime: '09:30',
})
expect(result).toBe('09 30 * * 1')
})
it('should convert monthly schedule to cron', () => {
const result = convertScheduleOptionsToCron('monthly', {
monthlyDay: '15',
monthlyTime: '12:00',
})
expect(result).toBe('12 00 15 * *')
})
it('should use custom cron expression directly', () => {
const customCron = '*/15 9-17 * * 1-5'
const result = convertScheduleOptionsToCron('custom', { cronExpression: customCron })
expect(result).toBe(customCron)
})
it('should throw error for unsupported schedule type', () => {
expect(() => convertScheduleOptionsToCron('invalid', {})).toThrow('Unsupported schedule type')
})
it('should use default values when options are not provided', () => {
const result = convertScheduleOptionsToCron('daily', {})
expect(result).toBe('00 09 * * *')
})
})
describe('date formatting functions', () => {
it('should format datetime correctly', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatDateTime(date)
expect(result).toMatch(/May 15, 2023/)
expect(result).toMatch(/2:30 PM|14:30/)
})
it('should format date correctly', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatDate(date)
expect(result).toMatch(/May 15, 2023/)
expect(result).not.toMatch(/2:30|14:30/)
})
it('should format time correctly', () => {
const date = new Date('2023-05-15T14:30:00')
const result = formatTime(date)
expect(result).toMatch(/2:30 PM|14:30/)
expect(result).not.toMatch(/2023|May/)
})
})
describe('formatDuration', () => {
it('should format milliseconds correctly', () => {
const result = formatDuration(500)
expect(result).toBe('500ms')
})
it('should format seconds correctly', () => {
const result = formatDuration(5000)
expect(result).toBe('5s')
})
it('should format minutes and seconds correctly', () => {
const result = formatDuration(125000) // 2m 5s
expect(result).toBe('2m 5s')
})
it('should format hours, minutes correctly', () => {
const result = formatDuration(3725000) // 1h 2m 5s
expect(result).toBe('1h 2m')
})
})

View File

@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from 'clsx'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'crypto'
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'
import { nanoid } from 'nanoid'
import { twMerge } from 'tailwind-merge'
import { createLogger } from '@/lib/logs/console-logger'
@@ -121,20 +122,6 @@ export function convertScheduleOptionsToCron(
}
}
export async function generateApiKey(): Promise<string> {
const buffer = randomBytes(32)
const hash = createHash('sha256').update(buffer).digest('hex')
return `wf_${hash}`
}
export async function validateApiKey(
apiKey: string | null,
storedApiKey: string | null
): Promise<boolean> {
if (!apiKey || !storedApiKey) return false
return apiKey === storedApiKey
}
/**
* Format a date into a human-readable format
* @param date - The date to format
@@ -202,3 +189,11 @@ export function formatDuration(durationMs: number): string {
const remainingMinutes = minutes % 60
return `${hours}h ${remainingMinutes}m`
}
/**
* Generates a standardized API key with the 'sim_' prefix
* @returns A new API key string
*/
export function generateApiKey(): string {
return `sim_${nanoid(32)}`
}