Skip to content
33 changes: 33 additions & 0 deletions apps/sim/app/api/copilot/api-keys/generate/route.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { generateShortId } from '@sim/utils/id'
import { type NextRequest, NextResponse } from 'next/server'
import { generateCopilotApiKeyContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
import { createApiKey } from '@/lib/api-key/auth'
import { hashApiKey } from '@/lib/api-key/crypto'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const POST = withRouteHandler(async (req: NextRequest) => {
Expand All @@ -22,6 +28,33 @@ export const POST = withRouteHandler(async (req: NextRequest) => {

const { name } = parsed.data.body

// Self-hosted: create the key locally as a personal API key. The
// Mothership service does not trust self-hosted callers, so the
// remote generation flow always fails. Local keys work everywhere
// (X-API-Key on /api/mcp/copilot, /api/v1/workflows/{id}/execute, etc.).
if (!isHosted) {
const { key: plainKey, encryptedKey } = await createApiKey(true)
if (!encryptedKey) {
return NextResponse.json({ error: 'Failed to encrypt API key' }, { status: 500 })
}
const id = generateShortId()
await db.insert(apiKeyTable).values({
id,
userId,
workspaceId: null,
name,
key: encryptedKey,
keyHash: hashApiKey(plainKey),
type: 'personal',
createdAt: new Date(),
updatedAt: new Date(),
})
return NextResponse.json(
{ success: true, key: { id, apiKey: plainKey } },
{ status: 201 }
)
}

const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/generate`, {
method: 'POST',
headers: {
Expand Down
44 changes: 44 additions & 0 deletions apps/sim/app/api/copilot/api-keys/route.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { db } from '@sim/db'
import { apiKey as apiKeyTable } from '@sim/db/schema'
import { and, desc, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { deleteCopilotApiKeyQuerySchema } from '@/lib/api/contracts'
import { getSession } from '@/lib/auth'
import { SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import { env } from '@/lib/core/config/env'
import { isHosted } from '@/lib/core/config/feature-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

export const GET = withRouteHandler(async (request: NextRequest) => {
Expand All @@ -16,6 +20,33 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

const userId = session.user.id

// Self-hosted: list user's personal keys directly from the api_key
// table. These are the same keys the generate endpoint creates and
// are valid for X-API-Key auth on /api/mcp/copilot (via the
// local-first lookup in route.ts).
if (!isHosted) {
const rows = await db
.select({
id: apiKeyTable.id,
name: apiKeyTable.name,
keyHash: apiKeyTable.keyHash,
createdAt: apiKeyTable.createdAt,
lastUsed: apiKeyTable.lastUsed,
})
.from(apiKeyTable)
.where(and(eq(apiKeyTable.userId, userId), eq(apiKeyTable.type, 'personal')))
.orderBy(desc(apiKeyTable.createdAt))
const keys = rows.map((k) => ({
id: k.id,
// Last 6 of keyHash for display (we don't decrypt the stored key here)
displayKey: `•••••${(k.keyHash || '').slice(-6)}`,
name: k.name,
createdAt: k.createdAt ? k.createdAt.toISOString() : null,
lastUsed: k.lastUsed ? k.lastUsed.toISOString() : null,
}))
return NextResponse.json({ keys }, { status: 200 })
}

const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/get-api-keys`, {
method: 'POST',
headers: {
Expand Down Expand Up @@ -75,6 +106,19 @@ export const DELETE = withRouteHandler(async (request: NextRequest) => {
}
const { id } = queryResult.data

// Self-hosted: delete the row directly. Scoped to the requesting user
// so callers can only delete their own keys.
if (!isHosted) {
const deleted = await db
.delete(apiKeyTable)
.where(and(eq(apiKeyTable.id, id), eq(apiKeyTable.userId, userId)))
.returning({ id: apiKeyTable.id })
if (deleted.length === 0) {
return NextResponse.json({ error: 'Key not found' }, { status: 404 })
}
return NextResponse.json({ success: true }, { status: 200 })
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-hosted DELETE endpoint missing key type filter

Medium Severity

The self-hosted GET handler correctly scopes its query to eq(apiKeyTable.type, 'personal'), but the self-hosted DELETE handler omits this filter — it deletes any key matching (id, userId) regardless of type. Since workspace keys also carry a userId, a caller who knows (or guesses) a workspace key ID can delete it through the Copilot Keys endpoint, even though Copilot Keys are only meant to manage personal-type keys.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 04be306. Configure here.


const res = await fetchGo(`${SIM_AGENT_API_URL}/api/validate-key/delete`, {
method: 'POST',
headers: {
Expand Down
29 changes: 28 additions & 1 deletion apps/sim/app/api/mcp/copilot/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
type RequestId,
} from '@modelcontextprotocol/sdk/types.js'
import { db } from '@sim/db'
import { userStats } from '@sim/db/schema'
import { apiKey as apiKeyTable, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
Expand All @@ -22,7 +22,9 @@ import { mcpRequestBodySchema, mcpToolCallParamsSchema } from '@/lib/api/contrac
import { validateOAuthAccessToken } from '@/lib/auth/oauth-token'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
import { hashApiKey } from '@/lib/api-key/crypto'
import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants'
import { isHosted } from '@/lib/core/config/feature-flags'
import { createRequestId } from '@/lib/copilot/request/http'
import { runHeadlessCopilotLifecycle } from '@/lib/copilot/request/lifecycle/headless'
import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent'
Expand Down Expand Up @@ -52,8 +54,33 @@ interface CopilotKeyAuthResult {
/**
* Validates a copilot API key by forwarding it to the Go copilot service's
* `/api/validate-key` endpoint. Returns the associated userId on success.
*
* On self-hosted instances, also accepts local workspace/personal API keys
* (sk-sim-...) by looking them up directly in the api_key table — the
* Mothership service does not trust self-hosted INTERNAL_API_SECRETs, so
* remote validation always fails. The local lookup gives external MCP
* clients (claude.ai, etc.) a way to authenticate using the same
* workspace API key they use for /api/v1/workflows execution.
*/
async function authenticateCopilotApiKey(apiKey: string): Promise<CopilotKeyAuthResult> {
if (!isHosted) {
try {
const keyHash = hashApiKey(apiKey)
const [row] = await db
.select({ userId: apiKeyTable.userId, expiresAt: apiKeyTable.expiresAt })
.from(apiKeyTable)
.where(eq(apiKeyTable.keyHash, keyHash))
.limit(1)
if (row?.userId && (!row.expiresAt || row.expiresAt > new Date())) {
return { success: true, userId: row.userId }
}
} catch (error) {
logger.warn('Local Copilot API key lookup failed, falling through to remote validation', {
error: toError(error).message,
})
}
}
Comment on lines +66 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 lastUsed is never updated in the self-hosted local-lookup path. On hosted instances the Go service records key usage; on self-hosted this field will remain null for every key, so the "Last used" column in the Copilot Keys settings page will always show as empty. Consider adding a fire-and-forget db.update(apiKeyTable).set({ lastUsed: new Date() }).where(eq(apiKeyTable.keyHash, keyHash)) after returning success.

Comment on lines +74 to +82
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When a locally-found key is expired (row.expiresAt <= new Date()), the function falls through to the remote Mothership validation. On self-hosted, Mothership rejects the call and the caller receives "Invalid Copilot API key — generate a new key" rather than a more accurate "key has expired" message. Adding an explicit early-return for the expired case would give a clearer error and avoid the unnecessary remote round-trip.


try {
const internalSecret = env.INTERNAL_API_SECRET
if (!internalSecret) {
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/workspace/[workspaceId]/settings/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,14 +142,16 @@ export const allNavigationItems: NavigationItem[] = [
label: 'BYOK',
icon: KeySquare,
section: 'system',
requiresHosted: true,
// BYOK is functional on self-hosted via the byok.ts patch — surface in
// sidebar so users can manage workspace credentials from the UI.
},
{
id: 'copilot',
label: 'Copilot Keys',
icon: HexSimple,
section: 'system',
requiresHosted: true,
// Copilot keys page works locally for managing keys (proxies to sim.ai
// for new key generation but listing/storing existing keys works).
Comment on lines +153 to +154
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The comment is stale — commit 7 (generate/route.ts) adds a local-DB key-generation path on !isHosted, so new keys are no longer proxied to sim.ai on self-hosted instances. The comment will mislead future readers into thinking generation still depends on Mothership.

Suggested change
// Copilot keys page works locally for managing keys (proxies to sim.ai
// for new key generation but listing/storing existing keys works).
// Copilot keys page works fully locally on self-hosted (generate,
// list, and delete all operate against the local api_key table).

},
{
id: 'inbox',
Expand Down
6 changes: 5 additions & 1 deletion apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,11 @@ export function getProviderCredentialSubBlocks(): SubBlockConfig[] {
placeholder: 'Enter your API key',
password: true,
connectionDroppable: false,
required: true,
// On self-hosted, BYOK keys (workspace_byok_keys table) provide
// credentials at execution time — see lib/api-key/byok.ts. Per-block
// apiKey override stays available but is no longer required, so
// workflows authored without one don't fail serializer validation.
required: isHosted,
condition: getApiKeyCondition(),
},
{
Expand Down
15 changes: 15 additions & 0 deletions apps/sim/lib/api-key/byok.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ export async function getApiKeyWithBYOK(

const byokProviderId = isGeminiModel ? 'google' : (provider as BYOKProviderId)

// On self-hosted, BYOK keys take precedence over a per-block apiKey for any
// supported provider — no hosted-model gate. Hosted environments retain the
// original behavior (BYOK only consulted for hosted-tier models).
if (
!isHosted &&
workspaceId &&
(isOpenAIModel || isClaudeModel || isGeminiModel || isMistralModel)
) {
const byokResult = await getBYOKKey(workspaceId, byokProviderId)
if (byokResult) {
logger.info('Using BYOK key (self-hosted)', { provider, model, workspaceId })
return byokResult
}
}

if (
isHosted &&
workspaceId &&
Expand Down
Loading