Skip to content

Commit 56389ed

Browse files
committed
Merge remote-tracking branch 'origin/staging' into wip/platform-org-wiring
2 parents effe2f1 + 1b1639c commit 56389ed

58 files changed

Lines changed: 650 additions & 257 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/docs/content/docs/platform/self-hosting/networking.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ Most `/api` requests also pass through Next.js Proxy. The current app configurat
265265
266266
The app makes outbound calls to model providers, integration APIs, your email provider, object storage, and your telemetry backend. Whether `HTTP_PROXY` / `HTTPS_PROXY` apply depends on which of those paths a call takes — there is no single answer, and no global setting that covers all of them.
267267
268-
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables, with the same rules, for every request it sends. The rest either build their own HTTP agent or speak a non-HTTP protocol.
268+
The server runs on Bun, and Bun's native `fetch` honors `$HTTP_PROXY`, `$HTTPS_PROXY`, and `$NO_PROXY`. Sim installs no global dispatcher. Default `fetch` uses the configured proxy unless `NO_PROXY` excludes the destination. The SSRF guard reads the same three variables for every request it sends, matching `NO_PROXY` the way curl does (see [Internal destinations and `NO_PROXY`](#internal-destinations-and-no_proxy)). The rest either build their own HTTP agent or speak a non-HTTP protocol.
269269
270270
This depends on the runtime. The published images run Bun. If you build the standalone output and run it under Node instead, Node ignores these variables unless started with `NODE_USE_ENV_PROXY=1` (Node 22.21+ / 24.5+), and the `fetch`-based "Yes" rows stop being proxied — model providers, Resend, Gmail sending, the desktop update feed, and the telemetry relay. The Azure Blob, GCS and Azure Communication Services rows still hold: those SDKs read the proxy variables through their own agents rather than through `fetch`. See [Bun fetch](https://bun.sh/docs/runtime/networking/fetch) and [Node proxy configuration](https://nodejs.org/learn/http/enterprise-network-configuration).
271271

‎apps/docs/openapi-v2-resources.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4986,7 +4986,7 @@
49864986
"v2Enabled": true,
49874987
"keyType": "personal",
49884988
"expiresAt": null,
4989-
"scopes": ["api:read", "api:write"]
4989+
"scopes": ["api:read", "api:write", "search:read"]
49904990
}
49914991
}
49924992
}
@@ -18677,7 +18677,7 @@
1867718677
"v2Enabled": true,
1867818678
"keyType": "personal",
1867918679
"expiresAt": null,
18680-
"scopes": ["api:read", "api:write"]
18680+
"scopes": ["api:read", "api:write", "search:read"]
1868118681
}
1868218682
}
1868318683
]

‎apps/docs/openapi-v2-workflows.json‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14928,7 +14928,7 @@
1492814928
"description": "Execute a deployed workflow version."
1492914929
},
1493014930
"version": {
14931-
"description": "Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.",
14931+
"description": "Deployment version to execute instead of the active one. Requires a full-access API key or an OAuth access token with `api:write`, plus workspace write access, and the workflow must be deployed. An unknown version returns `404`.",
1493214932
"type": "integer",
1493314933
"minimum": 1,
1493414934
"maximum": 2147483647
@@ -15006,7 +15006,7 @@
1500615006
},
1500715007
"async": {
1500815008
"default": false,
15009-
"description": "Queue the run and return a 202 receipt. Requires an API key or OAuth access token and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).",
15009+
"description": "Queue the run and return a 202 receipt. Requires a full-access API key or an OAuth access token with `api:write`, and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).",
1501015010
"type": "boolean"
1501115011
},
1501215012
"executionTimeoutSeconds": {
@@ -15016,7 +15016,7 @@
1501615016
"maximum": 604800
1501715017
},
1501815018
"waitTimeoutSeconds": {
15019-
"description": "Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires an API key or OAuth access token. Cannot be combined with `async` or `stream`.",
15019+
"description": "Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires a full-access API key or an OAuth access token with `api:write`. Cannot be combined with `async` or `stream`.",
1502015020
"type": "integer",
1502115021
"minimum": 0,
1502215022
"maximum": 604800
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { dbChainMockFns, resetDbChainMock } from '@sim/testing'
2+
import { beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
const { createWorkspaceKey } = vi.hoisted(() => ({ createWorkspaceKey: vi.fn() }))
5+
vi.mock('@/lib/api-key/auth', () => ({
6+
createApiKey: vi.fn(),
7+
createWorkspaceApiKey: createWorkspaceKey,
8+
}))
9+
10+
import {
11+
performCreatePersonalApiKey,
12+
performCreateWorkspaceApiKey,
13+
} from '@/lib/api-key/orchestration'
14+
15+
const INTERNAL_DETAIL = 'connect ECONNREFUSED 10.0.0.5:5432 for role april'
16+
17+
describe('API key creation failures', () => {
18+
beforeEach(() => {
19+
resetDbChainMock()
20+
createWorkspaceKey.mockReset()
21+
})
22+
23+
it('never echoes an internal personal-key failure to the caller', async () => {
24+
dbChainMockFns.limit.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))
25+
26+
const result = await performCreatePersonalApiKey({ userId: 'user-1', name: 'CLI' })
27+
28+
expect(result).toMatchObject({ success: false, errorCode: 'internal' })
29+
expect(result.error).not.toContain('ECONNREFUSED')
30+
})
31+
32+
it('never echoes an internal workspace-key failure, but still names a conflict', async () => {
33+
createWorkspaceKey.mockRejectedValueOnce(new Error(INTERNAL_DETAIL))
34+
const internal = await performCreateWorkspaceApiKey({
35+
workspaceId: 'ws-1',
36+
userId: 'user-1',
37+
name: 'CLI',
38+
})
39+
expect(internal).toMatchObject({ success: false, errorCode: 'internal' })
40+
expect(internal.error).not.toContain('ECONNREFUSED')
41+
42+
createWorkspaceKey.mockRejectedValueOnce(new Error('A key named "CLI" already exists'))
43+
const conflict = await performCreateWorkspaceApiKey({
44+
workspaceId: 'ws-1',
45+
userId: 'user-1',
46+
name: 'CLI',
47+
})
48+
expect(conflict).toEqual({
49+
success: false,
50+
errorCode: 'conflict',
51+
error: 'A key named "CLI" already exists',
52+
})
53+
})
54+
})

‎apps/sim/lib/api-key/orchestration/index.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,8 @@ export async function performCreatePersonalApiKey(
208208
return { success: true, key: { ...created, key: plainKey } }
209209
} catch (error) {
210210
logger.error('Failed to create personal API key', { error })
211-
return { success: false, errorCode: 'internal', error: toError(error).message }
211+
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
212+
return { success: false, errorCode: 'internal', error: 'Failed to create API key' }
212213
}
213214
}
214215

@@ -266,10 +267,10 @@ export async function performCreateWorkspaceApiKey(
266267
} catch (error) {
267268
const message = toError(error).message
268269
logger.error('Failed to create workspace API key', { error })
269-
return {
270-
success: false,
271-
error: message,
272-
errorCode: message.includes('already exists') ? 'conflict' : 'internal',
270+
if (message.includes('already exists')) {
271+
return { success: false, error: message, errorCode: 'conflict' }
273272
}
273+
// The CLI poll route is unauthenticated, so an internal failure never echoes its cause.
274+
return { success: false, error: 'Failed to create API key', errorCode: 'internal' }
274275
}
275276
}

‎apps/sim/lib/api/contracts/v2/openapi/resources.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1939,7 +1939,7 @@ const declaredRoutes = [
19391939
v2Enabled: true,
19401940
keyType: 'personal',
19411941
expiresAt: null,
1942-
scopes: ['api:read', 'api:write'],
1942+
scopes: ['api:read', 'api:write', 'search:read'],
19431943
},
19441944
},
19451945
]

‎apps/sim/lib/api/contracts/v2/workflows.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1508,7 +1508,7 @@ export const v2WorkflowRunSelectionSchema = z.discriminatedUnion('source', [
15081508
version: versionNumberSchema
15091509
.optional()
15101510
.describe(
1511-
'Deployment version to execute instead of the active one. Requires an API key or OAuth access token with workspace write access, and the workflow must be deployed. An unknown version returns `404`.'
1511+
'Deployment version to execute instead of the active one. Requires a full-access API key or an OAuth access token with `api:write`, plus workspace write access, and the workflow must be deployed. An unknown version returns `404`.'
15121512
),
15131513
})
15141514
.strict(),
@@ -1599,7 +1599,7 @@ export const v2ExecuteWorkflowBodySchema = z
15991599
.optional()
16001600
.default(false)
16011601
.describe(
1602-
'Queue the run and return a 202 receipt. Requires an API key or OAuth access token and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).'
1602+
'Queue the run and return a 202 receipt. Requires a full-access API key or an OAuth access token with `api:write`, and a deployed run source. Cannot stream, select outputs, enable thinking/tool events, or include file-inlining options (`includeFileBase64` or `base64MaxBytes`).'
16031603
),
16041604
/**
16051605
* An upper bound on the request, not the effective timeout: the server
@@ -1628,7 +1628,7 @@ export const v2ExecuteWorkflowBodySchema = z
16281628
.max(MAX_WORKFLOW_EXECUTION_TIMEOUT_SECONDS)
16291629
.optional()
16301630
.describe(
1631-
'Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires an API key or OAuth access token. Cannot be combined with `async` or `stream`.'
1631+
'Wait at most this many seconds for a synchronous run; `0` waits until it settles. A run still going then continues in the background and the response carries its run receipt instead of its result: `202` on a JSON response, or a closing `detached` frame on an `application/x-ndjson` result stream, which keeps its `200`. The receipt is sent only once the run can be read on the runs resource, where it can also be cancelled. The run is admitted under asynchronous limits, and a dropped connection no longer cancels it. Requires a full-access API key or an OAuth access token with `api:write`. Cannot be combined with `async` or `stream`.'
16321632
),
16331633
stream: z
16341634
.boolean()

‎apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,26 @@ describe('v2 API key authentication', () => {
199199
)
200200
})
201201

202+
it('accepts a personal key whose owner has never had a ban state recorded', async () => {
203+
queueTableRows(schemaMock.apiKey, [
204+
{
205+
id: 'key-1',
206+
userId: 'user-1',
207+
workspaceId: null,
208+
type: 'personal',
209+
access: 'write',
210+
expiresAt: null,
211+
ownerUserId: 'user-1',
212+
userBanned: null,
213+
userSuspendedAt: null,
214+
},
215+
])
216+
217+
const result = await authenticateV2ApiKey({ apiKey: 'secret', bearer: null })
218+
219+
expect(result.principal).toEqual(createPersonalApiKeyPrincipal())
220+
})
221+
202222
it('refuses a personal key whose owner row is gone as invalid, expired or not', async () => {
203223
for (const expiresAt of [null, new Date(Date.now() - 1)]) {
204224
queueTableRows(schemaMock.apiKey, [
@@ -209,6 +229,7 @@ describe('v2 API key authentication', () => {
209229
type: 'personal',
210230
access: 'write',
211231
expiresAt,
232+
ownerUserId: null,
212233
userBanned: null,
213234
userSuspendedAt: null,
214235
},

‎apps/sim/lib/api/server/routes/v2-api-key-auth.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,16 @@ interface ApiKeyRow {
7777
type: string
7878
access: ApiKeyAccess
7979
expiresAt: Date | null
80+
/** The joined owner's id: `null` only when the owner row is gone (`user.banned` is itself nullable). */
81+
ownerUserId: string | null
8082
userBanned: boolean | null
8183
userSuspendedAt: Date | null
8284
}
8385

8486
function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow {
8587
if (!row) throw new V2ApiKeyUnauthenticatedError()
8688
if (row.type === 'personal' && row.workspaceId === null) {
87-
if (row.userBanned === null) {
89+
if (row.ownerUserId === null) {
8890
logger.error('Personal API key is missing its credential owner', { keyId: row.id })
8991
throw new V2ApiKeyUnauthenticatedError()
9092
}
@@ -118,6 +120,7 @@ async function authenticateApiKey(apiKeyHeader: string): Promise<V2ApiKeyAuthCon
118120
type: apiKey.type,
119121
access: apiKey.access,
120122
expiresAt: apiKey.expiresAt,
123+
ownerUserId: user.id,
121124
userBanned: user.banned,
122125
userSuspendedAt: user.suspendedAt,
123126
})

‎apps/sim/lib/core/config/feature-flags.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ const FEATURE_FLAGS = {
9191
},
9292
'api-key-read-only': {
9393
description:
94-
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off first: that older code also serves every existing read-only key as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
94+
'Allow creating read-only API keys. Turn on only after every pod runs a release that enforces key access, since older pods treat a read-only key as full access. Before rolling back below the release that added read-only keys, turn this off and revoke or replace every existing read-only key: turning the flag off only stops new ones, and that older code serves existing read-only keys as full access. Enforcement of existing read-only keys never depends on this flag. Global; disabled by default.',
9595
fallback: 'API_KEY_READ_ONLY',
9696
},
9797
'mothership-model-selector': {

0 commit comments

Comments
 (0)