Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
52fdc4b
improvement(api): pull in the v2 external endpoint surface
TheodoreSpeaks Jul 30, 2026
854f7d3
Merge origin/staging into feat/sim-cli
TheodoreSpeaks Jul 30, 2026
2676f49
feat(cli): sim CLI with AWS-style profiles and a platform key exchange
TheodoreSpeaks Jul 30, 2026
f5eb20b
Merge improvement/v2-endpoints: reconciled tables v2, usage endpoints
TheodoreSpeaks Jul 30, 2026
b29d694
feat(cli): generate the CLI's v2 API from the route contracts, add ta…
TheodoreSpeaks Jul 30, 2026
e8534dc
fix(cli): make the generated v2 API a fixed point of the formatter
TheodoreSpeaks Jul 30, 2026
6af4fdb
fix(cli-auth): wait for the workspace list before allowing approval
TheodoreSpeaks Jul 30, 2026
5d3785a
fix(cli-auth): name minted keys by timestamp, not date
TheodoreSpeaks Jul 30, 2026
e20a3e1
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Jul 31, 2026
936efcf
feat(cli): CLI contract for the v2 surface, incl. execution
TheodoreSpeaks Jul 31, 2026
1a3d004
feat(cli): yaml and text output formats
TheodoreSpeaks Aug 1, 2026
7ef8b49
Merge remote-tracking branch 'origin/improvement/v2-endpoints' into f…
TheodoreSpeaks Aug 1, 2026
e34372b
refactor(cli): output format is a profile setting, not a flag
TheodoreSpeaks Aug 1, 2026
3e423ba
feat(cli): runtime that builds every command from the contract
TheodoreSpeaks Aug 1, 2026
4a6ac48
fix(cli): review round 1 — flag lookup, terminal controls, download s…
TheodoreSpeaks Aug 1, 2026
0ca127c
fix(cli): review round 2 — body-cursor paging, timestamp sanitization
TheodoreSpeaks Aug 1, 2026
9c0317d
fix(cli): review round 3 — poll retry, download flush errors
TheodoreSpeaks Aug 1, 2026
678bdc4
fix(cli): review round 4 — repeated flags encode per field kind
TheodoreSpeaks Aug 1, 2026
681ee38
fix(cli): review round 5 — header sanitization, auth ordering, stale …
TheodoreSpeaks Aug 1, 2026
fb413dd
Merge remote-tracking branch 'origin/v2-api-spec' into feat/sim-cli
TheodoreSpeaks Aug 1, 2026
07620c3
Merge remote-tracking branch 'origin/improvement/v2-endpoints' into f…
TheodoreSpeaks Aug 1, 2026
9a0e1e3
feat(cli): pick up the new v2 domains; discover modules instead of li…
TheodoreSpeaks Aug 1, 2026
344a012
fix(cli): render single-key resource envelopes, and column the new do…
TheodoreSpeaks Aug 1, 2026
0a77c75
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Aug 1, 2026
676bd83
fix(cli): stop dropping nested fields, and emit exports as documents
TheodoreSpeaks Aug 1, 2026
7918784
feat(cli): JSON flags accept @file and @- alongside inline JSON
TheodoreSpeaks Aug 1, 2026
d1f386b
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Aug 1, 2026
eb9c1bb
feat(cli): wire the expanded v2 files surface
TheodoreSpeaks Aug 1, 2026
5154a92
feat(cli): sim files upload
TheodoreSpeaks Aug 1, 2026
b97987e
fix(cli): make tables rows query show the rows
TheodoreSpeaks Aug 1, 2026
89b4d9b
fix(cli): make boolean flags able to say false
TheodoreSpeaks Aug 1, 2026
0f31294
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Aug 4, 2026
a0fa865
feat(cli): pick up v2 workflow CRUD, table transfers, and list search
TheodoreSpeaks Aug 4, 2026
39a4a4f
feat(cli): sim tables import
TheodoreSpeaks Aug 4, 2026
4ece5b7
feat(cli): default tables import to a new table named after the file
TheodoreSpeaks Aug 4, 2026
3be6ed7
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Aug 4, 2026
bb09d3c
Merge branch 'improvement/v2-endpoints' into feat/sim-cli
TheodoreSpeaks Aug 4, 2026
0c7b64a
chore(cli): regenerate for the paginated table list
TheodoreSpeaks Aug 4, 2026
e1810a6
fix(cli): make tables rows create and tables columns run usable
TheodoreSpeaks Aug 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/test-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ jobs:
- name: Desktop bridge contract audit
run: bun run check:desktop-bridge

# The CLI's view of the v2 API is generated from the same Zod contracts
# the routes validate against, so a contract change that skips
# `generate:cli-api` would ship a client describing endpoints the server
# no longer has.
- name: Sim CLI API generation up to date
run: bun run check:cli-api

# Complements the bridge audit above, which compares against a snapshot
# this same PR is allowed to regenerate. This one derives every fact from
# the source both sides execute, so it has no such blind spot.
Expand Down
126 changes: 119 additions & 7 deletions apps/sim/app/api/cli/auth/approve/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import { createHash } from 'node:crypto'
import { createMockRequest } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockCreateApproval: vi.fn(),
mockEnforceUserRateLimit: vi.fn(),
}))
const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit, mockGetPermissions } =
vi.hoisted(() => ({
mockGetSession: vi.fn(),
mockCreateApproval: vi.fn(),
mockEnforceUserRateLimit: vi.fn(),
mockGetPermissions: vi.fn(),
}))

vi.mock('@/lib/auth', () => ({
auth: { api: { getSession: vi.fn() } },
Expand All @@ -24,6 +26,10 @@ vi.mock('@/lib/core/rate-limiter', () => ({
enforceUserRateLimit: mockEnforceUserRateLimit,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetPermissions,
}))

import { POST } from '@/app/api/cli/auth/approve/route'

const REQUEST = 'a'.repeat(43)
Expand All @@ -35,6 +41,7 @@ describe('POST /api/cli/auth/approve', () => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockEnforceUserRateLimit.mockResolvedValue(null)
mockCreateApproval.mockResolvedValue(undefined)
mockGetPermissions.mockResolvedValue('admin')
})

it('records the approval for the signed-in user', async () => {
Expand All @@ -43,7 +50,112 @@ describe('POST /api/cli/auth/approve', () => {
)
expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({ ok: true })
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
scope: 'copilot',
workspaceId: undefined,
workspaceBound: false,
})
})

it('defaults to the copilot scope so pre-scope terminals keep working', async () => {
await POST(createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE }))
expect(mockCreateApproval).toHaveBeenCalledWith(
'user-1',
REQUEST,
CHALLENGE,
expect.objectContaining({ scope: 'copilot' })
)
})

it('records a workspace binding when the approver is a workspace admin', async () => {
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'platform',
workspaceId: 'ws-1',
bindKeyToWorkspace: true,
})
)
expect(response.status).toBe(200)
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
scope: 'platform',
workspaceId: 'ws-1',
workspaceBound: true,
})
})

it("records a non-admin's pick as a default without binding the key to it", async () => {
mockGetPermissions.mockResolvedValue('write')
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'platform',
workspaceId: 'ws-1',
})
)
expect(response.status).toBe(200)
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, {
scope: 'platform',
workspaceId: 'ws-1',
workspaceBound: false,
})
})

it('refuses to bind a key to a workspace the approver is not admin of', async () => {
mockGetPermissions.mockResolvedValue('write')
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'platform',
workspaceId: 'ws-1',
bindKeyToWorkspace: true,
})
)
expect(response.status).toBe(403)
expect(mockCreateApproval).not.toHaveBeenCalled()
})

it('refuses a workspace the approver is not a member of', async () => {
mockGetPermissions.mockResolvedValue(null)
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'platform',
workspaceId: 'ws-1',
})
)
expect(response.status).toBe(404)
expect(mockCreateApproval).not.toHaveBeenCalled()
})

it('refuses bindKeyToWorkspace with no workspaceId', async () => {
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'platform',
bindKeyToWorkspace: true,
})
)
expect(response.status).toBe(400)
expect(mockCreateApproval).not.toHaveBeenCalled()
})

it('refuses a workspace binding on the copilot scope', async () => {
const response = await POST(
createMockRequest('POST', {
request: REQUEST,
challenge: CHALLENGE,
scope: 'copilot',
workspaceId: 'ws-1',
})
)
expect(response.status).toBe(400)
expect(mockCreateApproval).not.toHaveBeenCalled()
})

it('rejects an unauthenticated caller', async () => {
Expand All @@ -59,7 +171,7 @@ describe('POST /api/cli/auth/approve', () => {
await POST(
createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' })
)
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE, expect.anything())
})

it('rejects a malformed challenge', async () => {
Expand Down
57 changes: 55 additions & 2 deletions apps/sim/app/api/cli/auth/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getSession } from '@/lib/auth'
import { createApproval } from '@/lib/cli-auth/approval-store'
import { enforceUserRateLimit } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CliAuthApproveAPI')

Expand All @@ -16,6 +17,10 @@ const logger = createLogger('CliAuthApproveAPI')
* The approving user comes from the session and nothing else — a client-supplied
* user id here would let any caller approve a request redeemable for someone
* else's key. No key is generated until the CLI polls.
*
* Workspace binding is authorized here rather than at poll time: the poll is
* unauthenticated by necessity, so it has no session to check a permission
* against. Approving is the only moment a human is present.
*/
export const POST = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
Expand All @@ -29,8 +34,56 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(approveCliAuthContract, request, {})
if (!parsed.success) return parsed.response

await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge)
logger.info('Recorded CLI authorization approval', { userId: session.user.id })
const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body

if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') {
return NextResponse.json(
{ error: 'workspaceId is only valid for the platform scope' },
{ status: 400 }
)
}

if (bindKeyToWorkspace && !workspaceId) {
return NextResponse.json(
{ error: 'bindKeyToWorkspace requires a workspaceId' },
{ status: 400 }
)
}

if (workspaceId) {
const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId)

// Reading the workspace at all requires membership. Without this, the
// terminal could be handed the id of a workspace the approver cannot see —
// harmless for the key, but it would silently become the profile default and
// every later command would 403 with no explanation.
if (!permission) {
return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
}

// Minting a workspace key is an admin action wherever else it is offered;
// the terminal is not a lower bar. Rejected outright rather than downgraded
// to a personal key, so the CLI never quietly stores a different credential
// than the browser said it would.
if (bindKeyToWorkspace && permission !== 'admin') {
return NextResponse.json(
{ error: 'Workspace admin permission is required to issue a workspace API key' },
{ status: 403 }
)
}
}

await createApproval(session.user.id, requestId, challenge, {
scope,
workspaceId,
workspaceBound: bindKeyToWorkspace,
})
logger.info('Recorded CLI authorization approval', {
userId: session.user.id,
scope,
workspaceId: workspaceId ?? null,
workspaceBound: bindKeyToWorkspace,
})

return NextResponse.json({ ok: true })
})
Loading