fix(apikeys): pinned api key to track API key a workflow is deployed with (#924)

* fix(apikeys): pinned api key to track API key a workflow is deployed with

* remove deprecated behaviour tests
This commit is contained in:
Vikhyath Mondreti
2025-08-08 23:37:27 -07:00
committed by GitHub
parent a2040322e7
commit ebb25469ab
9 changed files with 6056 additions and 253 deletions

View File

@@ -118,44 +118,49 @@ describe('Workflow Deployment API Route', () => {
db: {
select: vi.fn().mockImplementation(() => {
selectCallCount++
const buildLimitResponse = () => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
})
return {
from: vi.fn().mockImplementation(() => ({
where: vi.fn().mockImplementation(() => ({
limit: vi.fn().mockImplementation(() => {
// First call: workflow lookup (should return workflow)
if (selectCallCount === 1) {
return Promise.resolve([{ userId: 'user-id', id: 'workflow-id' }])
}
// Second call: blocks lookup
if (selectCallCount === 2) {
return Promise.resolve([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
])
}
// Third call: edges lookup
if (selectCallCount === 3) {
return Promise.resolve([])
}
// Fourth call: subflows lookup
if (selectCallCount === 4) {
return Promise.resolve([])
}
// Fifth call: API key lookup (should return empty for new key test)
if (selectCallCount === 5) {
return Promise.resolve([])
}
// Default: empty array
return Promise.resolve([])
}),
...buildLimitResponse(),
orderBy: vi.fn().mockReturnValue(buildLimitResponse()),
})),
})),
}
@@ -216,160 +221,7 @@ describe('Workflow Deployment API Route', () => {
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 () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // No existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_testkeygenerated12345')
expect(data).toHaveProperty('isDeployed', true)
expect(data).toHaveProperty('deployedAt')
})
/**
* Test POST deployment with existing API key
* This should use the existing API key
*/
it('should use existing API key when deploying workflow', async () => {
// Override the global mock for this specific test
vi.doMock('@/db', () => ({
db: {
select: vi
.fn()
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ userId: 'user-id', id: 'workflow-id' }]),
}),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([
{
id: 'block-1',
type: 'starter',
name: 'Start',
positionX: '100',
positionY: '100',
enabled: true,
subBlocks: {},
data: {},
},
]),
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No edges
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]), // No subflows
}),
})
.mockReturnValueOnce({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ key: 'sim_existingtestapikey12345' }]), // Existing API key
}),
}),
}),
insert: vi.fn().mockImplementation(() => ({
values: vi.fn().mockResolvedValue([{ id: 'mock-api-key-id' }]),
})),
update: vi.fn().mockImplementation(() => ({
set: vi.fn().mockImplementation(() => ({
where: vi.fn().mockResolvedValue([]),
})),
})),
},
}))
const req = createMockRequest('POST')
const params = Promise.resolve({ id: 'workflow-id' })
const { POST } = await import('@/app/api/workflows/[id]/deploy/route')
const response = await POST(req, { params })
expect(response.status).toBe(200)
const data = await response.json()
expect(data).toHaveProperty('apiKey', 'sim_existingtestapikey12345')
expect(data).toHaveProperty('isDeployed', true)
})
// Removed two POST deployment tests by request
/**
* Test DELETE undeployment

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, desc, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
@@ -33,6 +33,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
deployedAt: workflow.deployedAt,
userId: workflow.userId,
deployedState: workflow.deployedState,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -56,37 +57,42 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
})
}
// Fetch the user's API key
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.limit(1)
let userKey: string | null = null
let userKey = null
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKey = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKey,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKey
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
if (workflowData.pinnedApiKey) {
userKey = workflowData.pinnedApiKey
} else {
userKey = userApiKey[0].key
// Fetch the user's API key, preferring the most recently used
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, workflowData.userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
// If no API key exists, create one automatically
if (userApiKey.length === 0) {
try {
const newApiKeyVal = generateApiKey()
await db.insert(apiKey).values({
id: uuidv4(),
userId: workflowData.userId,
name: 'Default API Key',
key: newApiKeyVal,
createdAt: new Date(),
updatedAt: new Date(),
})
userKey = newApiKeyVal
logger.info(`[${requestId}] Generated new API key for user: ${workflowData.userId}`)
} catch (keyError) {
// If key generation fails, log the error but continue with the request
logger.error(`[${requestId}] Failed to generate API key:`, keyError)
}
} else {
userKey = userApiKey[0].key
}
}
// Check if the workflow has meaningful changes that would require redeployment
@@ -139,10 +145,11 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
return createErrorResponse(validation.error.message, validation.error.status)
}
// Get the workflow to find the user (removed deprecated state column)
// Get the workflow to find the user and existing pin (removed deprecated state column)
const workflowData = await db
.select({
userId: workflow.userId,
pinnedApiKey: workflow.pinnedApiKey,
})
.from(workflow)
.where(eq(workflow.id, id))
@@ -155,6 +162,17 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const userId = workflowData[0].userId
// Parse request body to capture selected API key (if provided)
let providedApiKey: string | null = null
try {
const parsed = await request.json()
if (parsed && typeof parsed.apiKey === 'string' && parsed.apiKey.trim().length > 0) {
providedApiKey = parsed.apiKey.trim()
}
} catch (_err) {
// Body may be empty; ignore
}
// Get the current live state from normalized tables instead of stale JSON
logger.debug(`[${requestId}] Getting current workflow state for deployment`)
@@ -241,13 +259,14 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
const deployedAt = new Date()
logger.debug(`[${requestId}] Proceeding with deployment at ${deployedAt.toISOString()}`)
// Check if the user already has an API key
// Check if the user already has API keys
const userApiKey = await db
.select({
key: apiKey.key,
})
.from(apiKey)
.where(eq(apiKey.userId, userId))
.orderBy(desc(apiKey.lastUsed), desc(apiKey.createdAt))
.limit(1)
let userKey = null
@@ -274,15 +293,42 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
userKey = userApiKey[0].key
}
// If client provided a specific API key and it belongs to the user, prefer it
if (providedApiKey) {
const [owned] = await db
.select({ key: apiKey.key })
.from(apiKey)
.where(and(eq(apiKey.userId, userId), eq(apiKey.key, providedApiKey)))
.limit(1)
if (owned) {
userKey = providedApiKey
}
}
// Update the workflow deployment status and save current state as deployed state
await db
.update(workflow)
.set({
isDeployed: true,
deployedAt,
deployedState: currentState,
})
.where(eq(workflow.id, id))
const updateData: any = {
isDeployed: true,
deployedAt,
deployedState: currentState,
}
// Only pin when the client explicitly provided a key in this request
if (providedApiKey) {
updateData.pinnedApiKey = userKey
}
await db.update(workflow).set(updateData).where(eq(workflow.id, id))
// Update lastUsed for the key we returned
if (userKey) {
try {
await db
.update(apiKey)
.set({ lastUsed: new Date(), updatedAt: new Date() })
.where(eq(apiKey.key, userKey))
} catch (e) {
logger.warn(`[${requestId}] Failed to update lastUsed for api key`)
}
}
logger.info(`[${requestId}] Workflow deployed successfully: ${id}`)
return createSuccessResponse({ apiKey: userKey, isDeployed: true, deployedAt })

View File

@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { createLogger } from '@/lib/logs/console/logger'
import { getWorkflowById } from '@/lib/workflows/utils'
@@ -56,22 +56,31 @@ export async function validateWorkflowAccess(
}
}
// 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))
// If a pinned key exists, only accept that specific key
if (workflow.pinnedApiKey) {
if (workflow.pinnedApiKey !== apiKeyHeader) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
} else {
// Otherwise, verify the key belongs to the workflow owner
const [owned] = await db
.select({ key: apiKey.key })
.from(apiKey)
.where(and(eq(apiKey.userId, workflow.userId), eq(apiKey.key, apiKeyHeader)))
.limit(1)
const validApiKey = userApiKeys.some((k) => k.key === apiKeyHeader)
if (!validApiKey) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
if (!owned) {
return {
error: {
message: 'Unauthorized: Invalid API key',
status: 401,
},
}
}
}
}

View File

@@ -59,6 +59,8 @@ interface DeployFormProps {
onSubmit: (data: DeployFormValues) => void
getInputFormatExample: () => string
onApiKeyCreated?: () => void
// Optional id to bind an external submit button via the `form` attribute
formId?: string
}
export function DeployForm({
@@ -69,6 +71,7 @@ export function DeployForm({
onSubmit,
getInputFormatExample,
onApiKeyCreated,
formId,
}: DeployFormProps) {
// State
const [isCreatingKey, setIsCreatingKey] = useState(false)
@@ -148,6 +151,7 @@ export function DeployForm({
return (
<Form {...form}>
<form
id={formId}
onSubmit={(e) => {
e.preventDefault()
onSubmit(form.getValues())

View File

@@ -178,6 +178,7 @@ export function DeployModal({
useEffect(() => {
async function fetchDeploymentInfo() {
// If not open or not deployed, clear info and stop
if (!open || !workflowId || !isDeployed) {
setDeploymentInfo(null)
if (!open) {
@@ -186,6 +187,12 @@ export function DeployModal({
return
}
// If we already have deploymentInfo (e.g., just deployed and set locally), avoid overriding it
if (deploymentInfo?.isDeployed && !needsRedeployment) {
setIsLoading(false)
return
}
try {
setIsLoading(true)
@@ -215,7 +222,7 @@ export function DeployModal({
}
fetchDeploymentInfo()
}, [open, workflowId, isDeployed, needsRedeployment])
}, [open, workflowId, isDeployed, needsRedeployment, deploymentInfo?.isDeployed])
const onDeploy = async (data: DeployFormValues) => {
setApiDeployError(null)
@@ -239,13 +246,13 @@ export function DeployModal({
throw new Error(errorData.error || 'Failed to deploy workflow')
}
const { isDeployed: newDeployStatus, deployedAt } = await response.json()
const { isDeployed: newDeployStatus, deployedAt, apiKey } = await response.json()
setDeploymentStatus(
workflowId,
newDeployStatus,
deployedAt ? new Date(deployedAt) : undefined,
data.apiKey
apiKey || data.apiKey
)
setNeedsRedeployment(false)
@@ -258,9 +265,9 @@ export function DeployModal({
const newDeploymentInfo = {
isDeployed: true,
deployedAt: deployedAt,
apiKey: data.apiKey,
apiKey: apiKey || data.apiKey,
endpoint,
exampleCommand: `curl -X POST -H "X-API-Key: ${data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
exampleCommand: `curl -X POST -H "X-API-Key: ${apiKey || data.apiKey}" -H "Content-Type: application/json"${inputFormatExample} ${endpoint}`,
needsRedeployment: false,
}
@@ -331,6 +338,9 @@ export function DeployModal({
}
await refetchDeployedState()
// Ensure modal status updates immediately
setDeploymentInfo((prev) => (prev ? { ...prev, needsRedeployment: false } : prev))
} catch (error: any) {
logger.error('Error redeploying workflow:', { error })
} finally {
@@ -437,7 +447,9 @@ export function DeployModal({
{isDeployed ? (
<DeploymentInfo
isLoading={isLoading}
deploymentInfo={deploymentInfo}
deploymentInfo={
deploymentInfo ? { ...deploymentInfo, needsRedeployment } : null
}
onRedeploy={handleRedeploy}
onUndeploy={handleUndeploy}
isSubmitting={isSubmitting}
@@ -464,6 +476,7 @@ export function DeployModal({
onSubmit={onDeploy}
getInputFormatExample={getInputFormatExample}
onApiKeyCreated={fetchApiKeys}
formId='deploy-api-form'
/>
</div>
</>
@@ -494,8 +507,8 @@ export function DeployModal({
</Button>
<Button
type='button'
onClick={() => onDeploy({ apiKey: apiKeys.length > 0 ? apiKeys[0].key : '' })}
type='submit'
form='deploy-api-form'
disabled={isSubmitting || (!keysLoaded && !apiKeys.length)}
className={cn(
'gap-2 font-medium',

View File

@@ -0,0 +1 @@
ALTER TABLE "workflow" ADD COLUMN "pinned_api_key" text;

File diff suppressed because it is too large Load Diff

View File

@@ -491,6 +491,13 @@
"when": 1754682155062,
"tag": "0070_charming_wrecking_crew",
"breakpoints": true
},
{
"idx": 71,
"version": "7",
"when": 1754719531015,
"tag": "0071_free_sharon_carter",
"breakpoints": true
}
]
}

View File

@@ -130,6 +130,8 @@ export const workflow = pgTable(
isDeployed: boolean('is_deployed').notNull().default(false),
deployedState: json('deployed_state'),
deployedAt: timestamp('deployed_at'),
// When set, only this API key is authorized for execution
pinnedApiKey: text('pinned_api_key'),
collaborators: json('collaborators').notNull().default('[]'),
runCount: integer('run_count').notNull().default(0),
lastRunAt: timestamp('last_run_at'),