From 8b0223ab1896688f2bbbd4ecb775440991f9dce6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:50:44 -0800 Subject: [PATCH 1/6] feat(api): agent identity on v2 meta, scoped CLI pairing keys, and v2 workspace rename - GET /api/v2/meta adds keyId, userId, workspaceId and links {openapi, docs, mcp}; existing fields unchanged. The API catalog shares the same link source. - CLI pairing login accepts --read-only and --expires-in . The request rides the approval URL, is shown on the approval card, is recorded with the approval and applied at mint; the unauthenticated poll cannot set either. The poll echoes access and expiresAt, and the CLI refuses to store a key that does not match what was asked (an older server mints full access). The signed-out login bounce now keeps scope, workspace, access and expiry instead of dropping them. sim whoami shows the key's expiry and bound workspace. - POST /api/v2/credentials/connections returns draftId. Opening a connection link that is unknown, expired or another account's now explains to sign in as the creating account, without saying which case applied. - PATCH /api/v2/workspaces/{workspaceId} renames a workspace or changes its logo through the existing workspaces.identity.update operation, widened to personal API keys and OAuth tokens (api:write); Admin still required, workspace keys still denied. Workspace update operations move into the operations registry. - Regenerated OpenAPI, MCP catalog, CLI client and CLI docs. --- apps/docs/content/docs/cli/commands.mdx | 3 +- apps/docs/content/docs/cli/reference.mdx | 24 +- apps/docs/content/docs/cli/workspaces.mdx | 21 ++ apps/docs/openapi-v2-resources.json | 271 +++++++++++++++++- .../scripts/fixtures/content-baseline.json | 14 + apps/sim/app/.well-known/api-catalog/route.ts | 9 +- .../api/auth/oauth2/authorize/route.test.ts | 16 ++ .../app/api/auth/oauth2/authorize/route.ts | 17 +- .../app/api/cli/auth/approve/route.test.ts | 14 + apps/sim/app/api/cli/auth/approve/route.ts | 25 +- apps/sim/app/api/cli/auth/poll/route.test.ts | 18 ++ apps/sim/app/api/cli/auth/poll/route.ts | 49 +++- .../api/v2/credentials/connections/route.ts | 3 +- apps/sim/app/api/v2/meta/route.ts | 6 +- .../api/v2/workspaces/[workspaceId]/route.ts | 42 ++- apps/sim/app/api/workspaces/[id]/route.ts | 6 +- apps/sim/app/cli/auth/cli-auth-request.ts | 50 +++- apps/sim/app/cli/auth/cli-auth-view.test.tsx | 2 + apps/sim/app/cli/auth/cli-auth-view.tsx | 19 +- apps/sim/app/cli/auth/page.test.tsx | 22 ++ apps/sim/app/cli/auth/page.tsx | 12 +- apps/sim/app/cli/auth/search-params.ts | 21 +- .../app/oauth/credential-connected/page.tsx | 5 +- .../application/read-v2-api-capabilities.ts | 12 + apps/sim/lib/api/contracts/cli-auth.ts | 33 +++ apps/sim/lib/api/contracts/v2/credentials.ts | 5 + apps/sim/lib/api/contracts/v2/meta.ts | 25 +- .../lib/api/contracts/v2/openapi/resources.ts | 53 +++- apps/sim/lib/api/contracts/v2/workspaces.ts | 36 +++ apps/sim/lib/api/links.ts | 21 ++ .../lib/api/mcp/generated/v2-operations.ts | 12 +- apps/sim/lib/cli-auth/approval-store.test.ts | 2 + apps/sim/lib/cli-auth/approval-store.ts | 19 +- apps/sim/lib/credentials/draft-constants.ts | 6 + .../lib/workspaces/application/operations.ts | 40 +++ .../application/update-workspace.ts | 54 ++-- apps/sim/lib/workspaces/public-queries.ts | 10 + packages/sim-cli/src/auth/device-flow.ts | 25 +- packages/sim-cli/src/commands/auth.test.ts | 22 ++ packages/sim-cli/src/commands/auth.ts | 190 +++++++++--- packages/sim-cli/src/contract/commands.ts | 24 +- packages/sim-cli/src/generated/v2-api.ts | 48 ++++ packages/sim-cli/src/http/client.test.ts | 1 + scripts/generate-cli-docs.ts | 2 +- 44 files changed, 1149 insertions(+), 160 deletions(-) create mode 100644 apps/sim/lib/api/links.ts diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index e998b13dc7b..bda103ff732 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -79,9 +79,10 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates an API key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | | `--no-browser` | No | Print the approval URL without opening it (either login method). | | `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--expires-in ` | No | API-key login only: days until the key expires (1-365); omit for a key that never expires. | | `--callback-port ` | No | Pin the local port the browser returns to. | | `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 981fdcef182..5e53873a271 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -47,9 +47,10 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates an API key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | | `--no-browser` | No | Print the approval URL without opening it (either login method). | | `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--expires-in ` | No | API-key login only: days until the key expires (1-365); omit for a key that never expires. | | `--callback-port ` | No | Pin the local port the browser returns to. | | `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | @@ -9239,6 +9240,27 @@ sim workspaces unlink [options] API request and response schemas: [unlinkWorkspaceFork](/api-reference/workspace-sync/unlinkWorkspaceFork). +### sim workspaces update + +Update workspace (OAuth login or personal API key required) + +```bash +sim workspaces update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New workspace display name. | +| `--logo-url ` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). | + + + +API request and response schemas: [updateWorkspace](/api-reference/workspaces/updateWorkspace). + ### sim workspaces sync-exclusions Update workspace fork exclusions (OAuth login or personal API key required) diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index cf195f61761..0729033516f 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -41,6 +41,27 @@ sim workspaces get API request and response schemas: [getWorkspace](/api-reference/workspaces/getWorkspace). +### Update workspace + +OAuth login or personal API key required. + +```bash +sim workspaces update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New workspace display name. | +| `--logo-url ` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). | + + + +API request and response schemas: [updateWorkspace](/api-reference/workspaces/updateWorkspace). + ### List workspace members ```bash diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index e5a56c40eb0..2c6a6949932 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -300,6 +300,121 @@ "$ref": "#/components/responses/ServiceUnavailable" } } + }, + "patch": { + "operationId": "updateWorkspace", + "summary": "Update workspace", + "description": "Rename a workspace or change its logo. Omitted fields remain unchanged; send `logoUrl: null` to remove the logo. Requires workspace Admin access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.identity.update", + "x-oauth-scope": "api:write", + "tags": ["Workspaces"], + "security": [ + { + "apiKey": [] + }, + { + "oauthBearer": ["api:write"] + } + ], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Workspace to update.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace to update." + } + } + ], + "requestBody": { + "required": true, + "description": "The workspace fields to change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceBody" + }, + "examples": { + "example1": { + "summary": "Request example 1", + "value": { + "name": "Platform Engineering" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated workspace.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceResponse" + }, + "examples": { + "example1": { + "summary": "Response example 1", + "value": { + "data": { + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Platform Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } } }, "/api/v2/workspaces/{workspaceId}/members": { @@ -4221,6 +4336,7 @@ "value": { "data": { "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "draftId": "draft-123", "expiresAt": "2026-06-20T14:17:11.000Z" } } @@ -5066,7 +5182,7 @@ "get": { "operationId": "getApiMeta", "summary": "Get API status", - "description": "Get the calling credential’s type, expiry, and API scopes. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.", + "description": "Get the calling credential’s type, identity, expiry, and API scopes, plus links to the OpenAPI document, API docs, and Sim MCP server. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.", "x-sim-operation": "meta.capabilities.read", "x-oauth-scope": "api:read", "tags": ["Meta"], @@ -5080,7 +5196,7 @@ ], "responses": { "200": { - "description": "Availability and lifecycle facts about the calling credential.", + "description": "Identity, availability, and lifecycle facts about the calling credential.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -5105,7 +5221,15 @@ "v2Enabled": true, "keyType": "personal", "expiresAt": null, - "scopes": ["api:read", "api:write", "search:read"] + "scopes": ["api:read", "api:write", "search:read"], + "keyId": "key_7f3a9c2e", + "userId": "user-123", + "workspaceId": null, + "links": { + "openapi": "https://docs.sim.ai/openapi.json", + "docs": "https://docs.sim.ai/api-reference/getting-started", + "mcp": "https://mcp.sim.ai/mcp" + } } } } @@ -15098,6 +15222,62 @@ } ] }, + "UpdateWorkspaceResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2Workspace" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workspace response", + "description": "The workspace after the update.", + "examples": [ + { + "data": { + "id": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Platform Engineering", + "color": "#33C482", + "logoUrl": null, + "memberCount": 14, + "createdAt": "2026-01-15T10:30:00.000Z", + "updatedAt": "2026-06-20T14:02:11.000Z" + } + } + ] + }, + "UpdateWorkspaceBody": { + "type": "object", + "properties": { + "name": { + "description": "New workspace display name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "logoUrl": { + "description": "New workspace logo URL, or null to remove the logo.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "title": "Update workspace body", + "description": "The workspace fields to change.", + "examples": [ + { + "name": "Platform Engineering" + } + ] + }, "V2WorkspaceMember": { "type": "object", "properties": { @@ -18155,6 +18335,10 @@ "format": "uri", "description": "Short-lived Sim browser URL that starts the OAuth authorization flow." }, + "draftId": { + "type": "string", + "description": "ID of the pending connection behind authorizationUrl. Only the user who created it can complete it." + }, "expiresAt": { "type": "string", "format": "date-time", @@ -18162,7 +18346,7 @@ "description": "ISO 8601 timestamp when the connection link expires." } }, - "required": ["authorizationUrl", "expiresAt"], + "required": ["authorizationUrl", "draftId", "expiresAt"], "additionalProperties": false, "title": "Credential connection authorization", "description": "A short-lived browser entrypoint for an OAuth connection flow." @@ -18183,6 +18367,7 @@ { "data": { "authorizationUrl": "https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123", + "draftId": "draft-123", "expiresAt": "2026-06-20T14:17:11.000Z" } } @@ -18933,12 +19118,74 @@ "enum": ["api:read", "api:write", "search:read"] }, "description": "API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies." + }, + "keyId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ID of the calling API key, or null for an OAuth access token." + }, + "userId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ID of the user the credential acts for, or null for a workspace API key, which acts as its workspace rather than as a user." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "The only workspace a workspace API key can reach, or null for personal API keys and OAuth access tokens, which reach every workspace their user can." + }, + "links": { + "type": "object", + "properties": { + "openapi": { + "type": "string", + "description": "URL of the OpenAPI document for this API." + }, + "docs": { + "type": "string", + "description": "URL of the API reference documentation." + }, + "mcp": { + "type": "string", + "description": "URL of the Sim MCP server." + } + }, + "required": ["openapi", "docs", "mcp"], + "additionalProperties": false, + "description": "Where to find the API description, its documentation, and the Sim MCP server." } }, - "required": ["v2Enabled", "keyType", "expiresAt", "scopes"], + "required": [ + "v2Enabled", + "keyType", + "expiresAt", + "scopes", + "keyId", + "userId", + "workspaceId", + "links" + ], "additionalProperties": false, "title": "API status", - "description": "API availability and lifecycle facts about the calling credential." + "description": "Identity, API availability, and lifecycle facts about the calling credential." }, "GetApiMetaResponse": { "type": "object", @@ -18951,14 +19198,22 @@ "required": ["data"], "additionalProperties": false, "title": "API status response", - "description": "API availability, credential type, expiry, and scopes for the caller.", + "description": "API availability, credential type and identity, expiry, scopes, and discovery links for the caller.", "examples": [ { "data": { "v2Enabled": true, "keyType": "personal", "expiresAt": null, - "scopes": ["api:read", "api:write", "search:read"] + "scopes": ["api:read", "api:write", "search:read"], + "keyId": "key_7f3a9c2e", + "userId": "user-123", + "workspaceId": null, + "links": { + "openapi": "https://docs.sim.ai/openapi.json", + "docs": "https://docs.sim.ai/api-reference/getting-started", + "mcp": "https://mcp.sim.ai/mcp" + } } } ] diff --git a/apps/docs/scripts/fixtures/content-baseline.json b/apps/docs/scripts/fixtures/content-baseline.json index 6694ecde8c8..4c5a4e461e4 100644 --- a/apps/docs/scripts/fixtures/content-baseline.json +++ b/apps/docs/scripts/fixtures/content-baseline.json @@ -13308,6 +13308,13 @@ "bytes": 31, "line": 9225 }, + { + "lang": "bash", + "meta": null, + "sha256": "ec9baafc46bf3e7425a387e9d92083a610028b2f7bf477ccc203fee669796cf1", + "bytes": 31, + "line": 9247 + }, { "lang": "bash", "meta": null, @@ -17813,6 +17820,13 @@ "bytes": 18, "line": 34 }, + { + "lang": "bash", + "meta": null, + "sha256": "ec9baafc46bf3e7425a387e9d92083a610028b2f7bf477ccc203fee669796cf1", + "bytes": 31, + "line": 48 + }, { "lang": "bash", "meta": null, diff --git a/apps/sim/app/.well-known/api-catalog/route.ts b/apps/sim/app/.well-known/api-catalog/route.ts index f82be29d6b1..07c4b655dd3 100644 --- a/apps/sim/app/.well-known/api-catalog/route.ts +++ b/apps/sim/app/.well-known/api-catalog/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' +import { getV2ApiLinks } from '@/lib/api/links' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { DOCS_URL } from '@/lib/help-links' /** RFC 9727 §4.2: the catalog is a Linkset carrying the RFC's profile. */ const API_CATALOG_CONTENT_TYPE = @@ -20,6 +20,7 @@ export const GET = withRouteHandler(() => { const baseUrl = getBaseUrl() const catalogUrl = `${baseUrl}/.well-known/api-catalog` const apiUrl = `${baseUrl}/api/v2` + const links = getV2ApiLinks() return NextResponse.json( { @@ -27,10 +28,8 @@ export const GET = withRouteHandler(() => { { anchor: catalogUrl, item: [{ href: apiUrl }] }, { anchor: apiUrl, - 'service-desc': [ - { href: `${DOCS_URL}/openapi.json`, type: 'application/vnd.oai.openapi+json' }, - ], - 'service-doc': [{ href: `${DOCS_URL}/api-reference/getting-started`, type: 'text/html' }], + 'service-desc': [{ href: links.openapi, type: 'application/vnd.oai.openapi+json' }], + 'service-doc': [{ href: links.docs, type: 'text/html' }], status: [{ href: `${baseUrl}/api/health`, type: 'application/json' }], }, ], diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index bd6c5c44124..12b13cc6080 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -53,6 +53,8 @@ vi.mock('@/lib/oauth/quickbooks-state', () => ({ createQuickBooksOAuthState: mocks.createQuickBooksState, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { OAUTH_LINK_UNAVAILABLE_ERROR } from '@/lib/credentials/draft-constants' import { GET as routeGET } from '@/app/api/auth/oauth2/authorize/route' const GET = (req: NextRequest) => routeGET(req, { params: Promise.resolve({}) }) @@ -458,6 +460,20 @@ describe('OAuth2 authorize route', () => { expect(response.headers.get('location')).toBe('https://provider.example/authorize') }) + it('tells a user who cannot open a draft to sign in as its creator, without saying whether it exists', async () => { + mocks.launchConnection.mockRejectedValueOnce( + new OrchestrationError('not_found', 'OAuth connection link is invalid or expired') + ) + + const response = await GET(request({ draftId: 'someone-elses-draft' })) + + const location = new URL(response.headers.get('location') ?? '') + expect(`${location.origin}${location.pathname}`).toBe(`${BASE_URL}/oauth/credential-connected`) + expect(Object.fromEntries(location.searchParams)).toEqual({ + error: OAUTH_LINK_UNAVAILABLE_ERROR, + }) + }) + it.each([ { callbackURL: undefined, diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index f241cf4cb10..af8a786077e 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -25,7 +25,10 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { launchScopedCredentialConnection } from '@/lib/credentials/application/launch-scoped-credential-connection' -import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' +import { + OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM, + OAUTH_LINK_UNAVAILABLE_ERROR, +} from '@/lib/credentials/draft-constants' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants' @@ -229,9 +232,17 @@ export const GET = withRouteHandler(async (request: NextRequest) => { }).catch((error: unknown) => { if (!(error instanceof OrchestrationError)) throw error logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return null + return error }) - if (!launched) { + if (launched instanceof OrchestrationError) { + // A draft is looked up by its id and the signed-in user together, so one + // that is unknown, expired, or another account's all read as not found. + // The reason names every case and does not say which one applied. + if (launched.code === 'not_found') { + const unavailableUrl = new URL('/oauth/credential-connected', baseUrl) + unavailableUrl.searchParams.set('error', OAUTH_LINK_UNAVAILABLE_ERROR) + return NextResponse.redirect(unavailableUrl) + } return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) } const { draft } = launched diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index 7118eee73f3..5671c2f2c01 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -79,6 +79,7 @@ describe('POST /api/cli/auth/approve', () => { scope: 'platform', workspaceId: 'ws-1', workspaceBound: false, + access: 'write', }) }) @@ -110,6 +111,19 @@ describe('POST /api/cli/auth/approve', () => { expect(mockCreateApproval).not.toHaveBeenCalled() }) + it('refuses a read-only or expiring key on the copilot scope', async () => { + const response = await POST( + createMockRequest('POST', { + request: REQUEST, + challenge: CHALLENGE, + scope: 'copilot', + access: 'read', + }) + ) + expect(response.status).toBe(400) + expect(mockCreateApproval).not.toHaveBeenCalled() + }) + /** * The workspace-key pass-through in the authorization funnel resolves no * group, so minting one is the only place the regime can be enforced. The diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index fa463ab9238..fa0015aa831 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -28,7 +28,7 @@ const logger = createLogger('CliAuthApproveAPI') * * That makes the approval record the sole carrier of the decision. The poll body * is a request id and a secret and nothing else, so it cannot assert a scope, a - * workspace, or a binding of its own — a refusal here writes no record, and a + * workspace, a binding, an access level, or a lifetime of its own — a refusal here writes no record, and a * poll driven directly against the same request id answers `pending` forever. */ export const POST = withRouteHandler(async (request: NextRequest) => { @@ -43,7 +43,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(approveCliAuthContract, request, {}) if (!parsed.success) return parsed.response - const { request: requestId, challenge, scope, workspaceId, bindKeyToWorkspace } = parsed.data.body + const { + request: requestId, + challenge, + scope, + workspaceId, + bindKeyToWorkspace, + access, + expiresInDays, + } = parsed.data.body if ((workspaceId || bindKeyToWorkspace) && scope !== 'platform') { return NextResponse.json( @@ -52,6 +60,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + // A copilot key has no access level or expiry to set; accepting either would + // record a consent the mint cannot honor. + if ((access !== 'write' || expiresInDays !== undefined) && scope !== 'platform') { + return NextResponse.json( + { error: 'access and expiresInDays are only valid for the platform scope' }, + { status: 400 } + ) + } + if (bindKeyToWorkspace && !workspaceId) { return NextResponse.json( { error: 'bindKeyToWorkspace requires a workspaceId' }, @@ -135,12 +152,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => { scope, workspaceId, workspaceBound: bindKeyToWorkspace, + access, + expiresInDays, }) logger.info('Recorded CLI authorization approval', { userId: session.user.id, scope, workspaceId: workspaceId ?? null, workspaceBound: bindKeyToWorkspace, + access, + expiresInDays: expiresInDays ?? null, }) return NextResponse.json({ ok: true }) diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 113e391c070..9cceb087ff5 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -55,6 +55,8 @@ function approved(overrides: Record = {}) { scope: 'copilot', workspaceId: null, workspaceBound: false, + access: 'write', + expiresInDays: null, ...overrides, } } @@ -113,6 +115,22 @@ describe('POST /api/cli/auth/poll', () => { expect(mockCreatePersonalApiKey).not.toHaveBeenCalled() }) + it('mints with the access and lifetime the approval recorded, whatever the poll body says', async () => { + mockPollApproval.mockResolvedValue( + approved({ scope: 'platform', access: 'read', expiresInDays: 30 }) + ) + const before = Date.now() + const response = await POST( + pollRequest({ request: REQUEST, verifier: VERIFIER, access: 'write', expiresInDays: null }) + ) + expect(response.status).toBe(200) + const [params] = mockCreatePersonalApiKey.mock.calls[0] + expect(params.access).toBe('read') + const lifetimeDays = (new Date(params.expiresAt).getTime() - before) / (24 * 60 * 60 * 1000) + expect(lifetimeDays).toBeGreaterThanOrEqual(30) + expect(lifetimeDays).toBeLessThan(30.01) + }) + /** * `/api/cli/auth/approve` is where the session exists to check workspace-admin * permission and the `api_keys.manage` capability, so it must be impossible to diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index 50517528091..d8a20117828 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' +import { DAY_MS } from '@/lib/api/contracts/api-keys' import { parseRequest } from '@/lib/api/server' import { apiKeyCreationErrorStatus, @@ -42,6 +43,19 @@ function cliKeyName(): string { return `CLI (${new Date().toISOString().slice(0, 19).replace('T', ' ')}Z)` } +/** + * Slack added to an approved lifetime. Key creation refuses an expiry under one + * day from its own clock reading, which comes a moment after this one, so a + * one-day approval would otherwise always be refused. + */ +const EXPIRY_SLACK_MS = 60_000 + +/** The expiry the approval asked for, counted from now; null for a key that never expires. */ +function approvedExpiresAt(grant: ApprovalGrant): string | null { + if (grant.expiresInDays === null) return null + return new Date(Date.now() + grant.expiresInDays * DAY_MS + EXPIRY_SLACK_MS).toISOString() +} + /** * Mints from the key space the approval recorded. * @@ -49,10 +63,13 @@ function cliKeyName(): string { * with second precision it means something genuinely unexpected, and silently * accumulating near-identical rows would hide it. */ -async function mintForGrant( - grant: ApprovalGrant -): Promise< - { ok: true; key: { id: string; apiKey: string } } | { ok: false; status: number; message: string } +async function mintForGrant(grant: ApprovalGrant): Promise< + | { + ok: true + key: { id: string; apiKey: string } + settings?: { access: 'read' | 'write'; expiresAt: string | null } + } + | { ok: false; status: number; message: string } > { const name = cliKeyName() @@ -70,7 +87,9 @@ async function mintForGrant( // key to it is a separate decision made at approval, where the session exists // to check workspace-admin permission and the `api_keys.manage` capability. // Nothing in the poll body can set it, so this branch can only be reached by - // an approval that already passed both. + // an approval that already passed both. Access and lifetime come from the + // same approval, for the same reason. + const settings = { access: grant.access, expiresAt: approvedExpiresAt(grant) } const result = grant.workspaceBound && grant.workspaceId ? await performCreateWorkspaceApiKey({ @@ -78,8 +97,14 @@ async function mintForGrant( userId: grant.userId, name, source: 'cli', + ...settings, + }) + : await performCreatePersonalApiKey({ + userId: grant.userId, + name, + source: 'cli', + ...settings, }) - : await performCreatePersonalApiKey({ userId: grant.userId, name, source: 'cli' }) if (!result.success || !result.key) { return { @@ -89,7 +114,14 @@ async function mintForGrant( } } - return { ok: true, key: { id: result.key.id, apiKey: result.key.key } } + return { + ok: true, + key: { id: result.key.id, apiKey: result.key.key }, + settings: { + access: result.key.access, + expiresAt: result.key.expiresAt?.toISOString() ?? null, + }, + } } /** @@ -134,6 +166,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { scope: result.scope, workspaceId: result.workspaceId, workspaceBound: result.workspaceBound, + access: minted.settings?.access, + expiresAt: minted.settings?.expiresAt, }) return NextResponse.json({ status: 'complete', @@ -141,5 +175,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { scope: result.scope, workspaceId: result.workspaceId, workspaceBound: result.workspaceBound, + ...minted.settings, }) }) diff --git a/apps/sim/app/api/v2/credentials/connections/route.ts b/apps/sim/app/api/v2/credentials/connections/route.ts index 7fef7f6ce2d..33fc0a9cb22 100644 --- a/apps/sim/app/api/v2/credentials/connections/route.ts +++ b/apps/sim/app/api/v2/credentials/connections/route.ts @@ -23,9 +23,10 @@ export const POST = defineV2JsonRoute({ errorPolicy: credentialConnectionErrorPolicy, mapInput: ({ body }) => body, useCase: createCredentialConnection, - present: ({ authorizationUrl, expiresAt }) => ({ + present: ({ authorizationUrl, draftId, expiresAt }) => ({ data: { authorizationUrl, + draftId, expiresAt: expiresAt.toISOString(), }, }), diff --git a/apps/sim/app/api/v2/meta/route.ts b/apps/sim/app/api/v2/meta/route.ts index 39231e06382..a46f4d65d9f 100644 --- a/apps/sim/app/api/v2/meta/route.ts +++ b/apps/sim/app/api/v2/meta/route.ts @@ -11,7 +11,7 @@ import { export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/meta — Report the calling credential's API availability and lifecycle. */ +/** GET /api/v2/meta — Report the calling credential's identity, API availability, and lifecycle. */ export const GET = defineV2JsonRoute({ contract: v2GetMetaContract, auth: v2ApiKeyAuth, @@ -24,7 +24,7 @@ export const GET = defineV2JsonRoute({ return { keyType: credential.keyType, expiresAt: credential.keyExpiresAt } }, useCase: readV2ApiCapabilities, - present: ({ v2Enabled, keyType, expiresAt, scopes }) => ({ - data: { v2Enabled, keyType, expiresAt: expiresAt?.toISOString() ?? null, scopes }, + present: ({ expiresAt, ...identity }) => ({ + data: { ...identity, expiresAt: expiresAt?.toISOString() ?? null }, }), }) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts index bdcd9092d8b..8db098a1ac3 100644 --- a/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/route.ts @@ -1,8 +1,26 @@ -import { v2GetWorkspaceContract } from '@/lib/api/contracts/v2/workspaces' +import { + v2GetWorkspaceContract, + v2UpdateWorkspaceContract, +} from '@/lib/api/contracts/v2/workspaces' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2WorkspaceErrorPolicies } from '@/lib/workspaces/api/route-policies' import { getPublicWorkspace } from '@/lib/workspaces/application/get-public-workspace' -import { workspaceOperations } from '@/lib/workspaces/application/operations' +import { + workspaceOperations, + workspaceUpdateOperations, +} from '@/lib/workspaces/application/operations' +import { updateWorkspaceIdentity } from '@/lib/workspaces/application/update-workspace' +import type { PublicWorkspaceDetail } from '@/lib/workspaces/public-queries' + +function presentWorkspace(workspace: PublicWorkspaceDetail) { + return { + data: { + ...workspace, + createdAt: workspace.createdAt.toISOString(), + updatedAt: workspace.updatedAt.toISOString(), + }, + } +} /** GET /api/v2/workspaces/[workspaceId] — Public workspace metadata. */ export const GET = defineV2JsonRoute({ @@ -13,11 +31,17 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, mapInput: ({ params }) => ({ workspaceId: params.workspaceId }), useCase: getPublicWorkspace, - present: ({ workspace }) => ({ - data: { - ...workspace, - createdAt: workspace.createdAt.toISOString(), - updatedAt: workspace.updatedAt.toISOString(), - }, - }), + present: ({ workspace }) => presentWorkspace(workspace), +}) + +/** PATCH /api/v2/workspaces/[workspaceId] — Rename a workspace or change its logo. */ +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateWorkspaceContract, + auth: v2ApiKeyAuth, + operation: workspaceUpdateOperations.updateIdentity, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkspaceErrorPolicies.concealWorkspaceAuthorization, + mapInput: ({ params, body }) => ({ workspaceId: params.workspaceId, patch: body }), + useCase: updateWorkspaceIdentity, + present: ({ workspace }) => presentWorkspace(workspace), }) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index db049b18fe5..8febfed45cc 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -24,10 +24,8 @@ import { NoWorkspaceAccessError, } from '@/lib/core/application' import { captureServerEvent } from '@/lib/posthog/server' -import { - updateWorkspace, - workspaceUpdateOperations, -} from '@/lib/workspaces/application/update-workspace' +import { workspaceUpdateOperations } from '@/lib/workspaces/application/operations' +import { updateWorkspace } from '@/lib/workspaces/application/update-workspace' import { archiveWorkspace } from '@/lib/workspaces/lifecycle' import { presentWorkspace } from '@/lib/workspaces/response' diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index 57a849b97b0..33ee2f5431f 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -1,4 +1,9 @@ -import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' +import type { ApiKeyAccess } from '@sim/auth/principal' +import { + CLI_AUTH_MAX_EXPIRY_DAYS, + CLI_AUTH_MIN_EXPIRY_DAYS, + type CliAuthScope, +} from '@/lib/api/contracts/cli-auth' /** BASE64URL, 43 chars (request id or SHA-256 challenge), no padding. */ const BASE64URL_43 = /^[A-Za-z0-9\-_]{43}$/ @@ -18,6 +23,10 @@ export interface CliAuthRequest { scope: CliAuthScope /** Workspace the terminal suggests preselecting. A hint only — never authority. */ suggestedWorkspaceId: string | null + /** Platform scope only: the access the terminal asked the key to have. */ + access: ApiKeyAccess + /** Platform scope only: days until the key expires, or null for a key that never does. */ + expiresInDays: number | null } export type CliAuthRequestResolution = @@ -30,6 +39,8 @@ interface RawCliAuthParams { pairing: string | null scope: CliAuthScope workspace: string | null + access: string | null + expires: string | null } /** @@ -42,6 +53,8 @@ export function resolveCliAuthRequest({ pairing, scope, workspace, + access, + expires, }: RawCliAuthParams): CliAuthRequestResolution { if (!request || !challenge || !pairing) { return { valid: false, reason: 'This link is missing the parameters the Sim CLI sends.' } @@ -55,8 +68,41 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'The pairing code is malformed.' } } + if ((access !== null || expires !== null) && scope !== 'platform') { + return { valid: false, reason: 'Key access and expiry only apply to Sim API keys.' } + } + + const keyAccess: ApiKeyAccess | null = + access === null ? 'write' : access === 'read' || access === 'write' ? access : null + if (!keyAccess) { + return { valid: false, reason: 'The requested key access is malformed.' } + } + + const expiresInDays = expires === null ? null : Number(expires) + if ( + expires !== null && + !( + /^\d+$/.test(expires) && + Number(expires) >= CLI_AUTH_MIN_EXPIRY_DAYS && + Number(expires) <= CLI_AUTH_MAX_EXPIRY_DAYS + ) + ) { + return { + valid: false, + reason: `The key expiry must be a whole number of days from ${CLI_AUTH_MIN_EXPIRY_DAYS} to ${CLI_AUTH_MAX_EXPIRY_DAYS}.`, + } + } + return { valid: true, - request: { request, challenge, pairing, scope, suggestedWorkspaceId: workspace || null }, + request: { + request, + challenge, + pairing, + scope, + suggestedWorkspaceId: workspace || null, + access: keyAccess, + expiresInDays, + }, } } diff --git a/apps/sim/app/cli/auth/cli-auth-view.test.tsx b/apps/sim/app/cli/auth/cli-auth-view.test.tsx index 8a50017df7a..da83c5409d0 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.test.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.test.tsx @@ -22,6 +22,8 @@ vi.mock('nuqs', () => ({ pairing: 'ABCD-2345', scope: 'platform', workspace: null, + access: null, + expires: null, }, ], })) diff --git a/apps/sim/app/cli/auth/cli-auth-view.tsx b/apps/sim/app/cli/auth/cli-auth-view.tsx index d96486d4296..db9a39efb05 100644 --- a/apps/sim/app/cli/auth/cli-auth-view.tsx +++ b/apps/sim/app/cli/auth/cli-auth-view.tsx @@ -94,6 +94,17 @@ export function CliAuthView() { : (selected ?? suggested ?? workspaces.data?.lastActiveWorkspaceId ?? null) const chosen = workspaces.data?.workspaces.find((w) => w.id === workspaceId) + /** + * The terminal's requested access and lifetime are stated before approval: + * they change what the key can do, and this card is the only place the user + * consents to them. + */ + const keyLifetime = + request.expiresInDays === null + ? '' + : ` that expires in ${request.expiresInDays} ${request.expiresInDays === 1 ? 'day' : 'days'}` + const keyDescription = `${request.access === 'read' ? 'a read-only' : 'a'} personal key tied to your account${keyLifetime}` + return (
)} @@ -166,6 +177,8 @@ export function CliAuthView() { // always mints a personal key so the profile can switch workspaces. ...(isPlatform && chosen ? { workspaceId: chosen.id } : {}), bindKeyToWorkspace: false, + access: request.access, + expiresInDays: request.expiresInDays ?? undefined, }, { onSuccess: () => router.push('/cli/auth/done') } ) diff --git a/apps/sim/app/cli/auth/page.test.tsx b/apps/sim/app/cli/auth/page.test.tsx index 24c9a7e2bbc..aa121932f58 100644 --- a/apps/sim/app/cli/auth/page.test.tsx +++ b/apps/sim/app/cli/auth/page.test.tsx @@ -42,4 +42,26 @@ describe('CliAuthPage signed-out bounce', () => { `NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}` ) }) + + it('keeps a requested read-only, expiring platform key through the bounce', async () => { + const error = await CliAuthPage({ + searchParams: Promise.resolve({ + request: REQUEST, + challenge: CHALLENGE, + pairing: PAIRING, + scope: 'platform', + access: 'read', + expires: '30', + }), + }).catch((e: unknown) => e) + + const location = new URL( + String((error as Error).message).replace('NEXT_REDIRECT:', ''), + 'https://sim.test' + ) + const callback = new URL(location.searchParams.get('callbackUrl') ?? '', 'https://sim.test') + expect(callback.searchParams.get('scope')).toBe('platform') + expect(callback.searchParams.get('access')).toBe('read') + expect(callback.searchParams.get('expires')).toBe('30') + }) }) diff --git a/apps/sim/app/cli/auth/page.tsx b/apps/sim/app/cli/auth/page.tsx index e49a47be4cc..ecfc2a9bf8a 100644 --- a/apps/sim/app/cli/auth/page.tsx +++ b/apps/sim/app/cli/auth/page.tsx @@ -9,7 +9,10 @@ import { AuthShell } from '@/app/(auth)/components' import { resolveCliAuthRequest } from '@/app/cli/auth/cli-auth-request' import { CliAuthView } from '@/app/cli/auth/cli-auth-view' import { CliAuthLoading } from '@/app/cli/auth/loading' -import { cliAuthSearchParamsCache } from '@/app/cli/auth/search-params' +import { + cliAuthSearchParamsCache, + serializeCliAuthSearchParams, +} from '@/app/cli/auth/search-params' export const metadata: Metadata = { title: 'Connect your terminal', @@ -50,14 +53,9 @@ export default async function CliAuthPage({ const resolution = resolveCliAuthRequest(params) if (resolution.valid && !session?.user) { - const query = new URLSearchParams({ - request: resolution.request.request, - challenge: resolution.request.challenge, - pairing: resolution.request.pairing, - }) redirect( buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', { - callbackUrl: `/cli/auth?${query}`, + callbackUrl: serializeCliAuthSearchParams('/cli/auth', params), isInviteFlow: false, }) ) diff --git a/apps/sim/app/cli/auth/search-params.ts b/apps/sim/app/cli/auth/search-params.ts index 375a1c77ab3..012375ea93b 100644 --- a/apps/sim/app/cli/auth/search-params.ts +++ b/apps/sim/app/cli/auth/search-params.ts @@ -1,4 +1,9 @@ -import { createSearchParamsCache, parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { + createSearchParamsCache, + createSerializer, + parseAsString, + parseAsStringLiteral, +} from 'nuqs/server' /** Key spaces the handoff can mint from. Mirrors `cliAuthScopeSchema`. */ export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const @@ -15,6 +20,11 @@ export const CLI_AUTH_SCOPES = ['copilot', 'platform'] as const * — which sent no scope — still lands on the key space it expects. `workspace` * is only a preselection hint for the picker; the workspace that ends up bound * to the key is the one the user confirms, and it is re-authorized server-side. + * + * `access` and `expires` (whole days) are what the terminal asked the platform + * key to be. Absent means a full-access key that never expires. Read as plain + * strings so a malformed value is refused by `resolveCliAuthRequest` rather than + * parsed to null and approved as the full-access, never-expiring default. */ export const cliAuthParsers = { request: parseAsString, @@ -22,6 +32,8 @@ export const cliAuthParsers = { pairing: parseAsString, scope: parseAsStringLiteral(CLI_AUTH_SCOPES).withDefault('copilot'), workspace: parseAsString, + access: parseAsString, + expires: parseAsString, } as const /** @@ -29,3 +41,10 @@ export const cliAuthParsers = { * login bounce from exactly the values the client component will re-read. */ export const cliAuthSearchParamsCache = createSearchParamsCache(cliAuthParsers) + +/** + * Re-serializes the handoff for the signed-out login bounce. Only params the + * page understands survive, and a value the terminal asked for — a read-only + * key, say — is carried through rather than silently dropped on the way back. + */ +export const serializeCliAuthSearchParams = createSerializer(cliAuthParsers) diff --git a/apps/sim/app/oauth/credential-connected/page.tsx b/apps/sim/app/oauth/credential-connected/page.tsx index 135514232b3..9a714c2da12 100644 --- a/apps/sim/app/oauth/credential-connected/page.tsx +++ b/apps/sim/app/oauth/credential-connected/page.tsx @@ -1,5 +1,6 @@ import { AuthLinkButton as ChipLink, AuthStatusContent as StatusPageContent } from '@sim/auth-ui' import type { Metadata } from 'next' +import { OAUTH_LINK_UNAVAILABLE_ERROR } from '@/lib/credentials/draft-constants' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { AuthShell as LogoShell } from '@/app/(auth)/components' @@ -27,7 +28,9 @@ export default async function CredentialConnectedPage({ description={ connected ? 'The credential is ready to use. You can close this tab and return to the app that started the connection.' - : 'The credential could not be connected. Return to the app that started the connection and try again.' + : error === OAUTH_LINK_UNAVAILABLE_ERROR + ? 'This connection link has expired or belongs to a different Sim account. Sign in as the account that created the link, or create a new one.' + : 'The credential could not be connected. Return to the app that started the connection and try again.' } > diff --git a/apps/sim/lib/api/application/read-v2-api-capabilities.ts b/apps/sim/lib/api/application/read-v2-api-capabilities.ts index 11239f862e7..04cb2f98263 100644 --- a/apps/sim/lib/api/application/read-v2-api-capabilities.ts +++ b/apps/sim/lib/api/application/read-v2-api-capabilities.ts @@ -1,5 +1,6 @@ import { v2MetaOperations } from '@/lib/api/application/operations' import type { V2ApiKeyType } from '@/lib/api/contracts/v2/meta' +import { getV2ApiLinks, type V2ApiLinks } from '@/lib/api/links' import type { OAuthApiScope } from '@/lib/auth/oauth-provider' import { assertOperationPrincipal, @@ -24,6 +25,13 @@ export interface ReadV2ApiCapabilitiesResult { keyType: V2ApiKeyType expiresAt: Date | null scopes: OAuthApiScope[] + /** The API key's id; null for an OAuth access token. */ + keyId: string | null + /** The user the credential acts for; null for a workspace key, which acts as its workspace. */ + userId: string | null + /** The one workspace a workspace key can reach; null for every other credential. */ + workspaceId: string | null + links: V2ApiLinks } /** @@ -46,6 +54,10 @@ export const readV2ApiCapabilities: OperationUseCase< keyType: input.keyType, expiresAt: input.expiresAt, scopes: credentialApiScopes(principal), + keyId: principal.kind === 'oauth_access_token' ? null : principal.keyId, + userId: principal.kind === 'workspace_api_key' ? null : principal.userId, + workspaceId: principal.kind === 'workspace_api_key' ? principal.workspaceId : null, + links: getV2ApiLinks(), } }, } diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index d37d7a597e0..c4f0e9759a4 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -29,6 +29,22 @@ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/ export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') export type CliAuthScope = z.output +/** Access a minted platform key carries: full (`write`) or read-only (`read`). */ +const cliAuthKeyAccessSchema = z.enum(['read', 'write']) + +/** + * Bounds on a minted platform key's lifetime, in whole days. The same window + * every other key-creation surface allows: one day to one year. + */ +export const CLI_AUTH_MIN_EXPIRY_DAYS = 1 +export const CLI_AUTH_MAX_EXPIRY_DAYS = 365 + +const cliAuthExpiresInDaysSchema = z + .number() + .int('expiresInDays must be a whole number of days') + .min(CLI_AUTH_MIN_EXPIRY_DAYS, `expiresInDays must be at least ${CLI_AUTH_MIN_EXPIRY_DAYS}`) + .max(CLI_AUTH_MAX_EXPIRY_DAYS, `expiresInDays cannot exceed ${CLI_AUTH_MAX_EXPIRY_DAYS}`) + export const approveCliAuthBodySchema = z.object({ request: base64Url43('request must be a base64url request id'), challenge: base64Url43('challenge must be a base64url-encoded SHA-256 digest'), @@ -50,6 +66,15 @@ export const approveCliAuthBodySchema = z.object({ * request did not come from that UI. */ bindKeyToWorkspace: z.boolean().optional().default(false), + /** + * Platform scope only: the access the key is minted with. The terminal asks + * for it in the browser URL and the approval page shows it before the user + * approves; recording it here, rather than reading it at poll time, is what + * keeps the unauthenticated poll from widening it. + */ + access: cliAuthKeyAccessSchema.optional().default('write'), + /** Platform scope only: days until the minted key expires. Absent means it never expires. */ + expiresInDays: cliAuthExpiresInDaysSchema.optional(), }) export type ApproveCliAuthBody = z.input @@ -102,6 +127,14 @@ export const pollCliAuthContract = defineRouteContract({ * profile somewhere the credential cannot follow. */ workspaceBound: z.boolean(), + /** + * Platform scope only: the access the key was minted with, as approved. + * A terminal that asked for read-only must check this rather than + * assume it — a server that predates the field mints full access. + */ + access: cliAuthKeyAccessSchema.optional(), + /** Platform scope only: when the key expires, or null when it never does. */ + expiresAt: z.string().nullable().optional(), }), ]), }, diff --git a/apps/sim/lib/api/contracts/v2/credentials.ts b/apps/sim/lib/api/contracts/v2/credentials.ts index 5441721d578..ac8d73e4938 100644 --- a/apps/sim/lib/api/contracts/v2/credentials.ts +++ b/apps/sim/lib/api/contracts/v2/credentials.ts @@ -390,6 +390,11 @@ export const v2CredentialConnectionAuthorizationSchema = z .string() .url('authorizationUrl must be an absolute URL') .describe('Short-lived Sim browser URL that starts the OAuth authorization flow.'), + draftId: z + .string() + .describe( + 'ID of the pending connection behind authorizationUrl. Only the user who created it can complete it.' + ), expiresAt: v2TimestampSchema.describe('ISO 8601 timestamp when the connection link expires.'), }) .meta({ diff --git a/apps/sim/lib/api/contracts/v2/meta.ts b/apps/sim/lib/api/contracts/v2/meta.ts index 08bb5a58fe9..6545700b34c 100644 --- a/apps/sim/lib/api/contracts/v2/meta.ts +++ b/apps/sim/lib/api/contracts/v2/meta.ts @@ -27,11 +27,34 @@ export const v2MetaSchema = z .describe( 'API scopes the calling credential can use. api:write implies api:read, and either implies search:read. A read-only API key holds api:read and search:read: it is refused every operation that changes state and every operation that declares no API scope, so api:read alone does not authorize those. A full-access key holds all three; an OAuth access token holds what it was granted and what that implies.' ), + keyId: z + .string() + .nullable() + .describe('ID of the calling API key, or null for an OAuth access token.'), + userId: z + .string() + .nullable() + .describe( + 'ID of the user the credential acts for, or null for a workspace API key, which acts as its workspace rather than as a user.' + ), + workspaceId: z + .string() + .nullable() + .describe( + 'The only workspace a workspace API key can reach, or null for personal API keys and OAuth access tokens, which reach every workspace their user can.' + ), + links: z + .object({ + openapi: z.string().describe('URL of the OpenAPI document for this API.'), + docs: z.string().describe('URL of the API reference documentation.'), + mcp: z.string().describe('URL of the Sim MCP server.'), + }) + .describe('Where to find the API description, its documentation, and the Sim MCP server.'), }) .meta({ id: 'V2Meta', title: 'API status', - description: 'API availability and lifecycle facts about the calling credential.', + description: 'Identity, API availability, and lifecycle facts about the calling credential.', }) export type V2Meta = z.output diff --git a/apps/sim/lib/api/contracts/v2/openapi/resources.ts b/apps/sim/lib/api/contracts/v2/openapi/resources.ts index 0091289bd34..a24849f18fd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/resources.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/resources.ts @@ -93,6 +93,7 @@ import { v2ListWorkspaceMembersContract, v2ListWorkspacesContract, v2RemoveWorkspaceMemberContract, + v2UpdateWorkspaceContract, v2UpdateWorkspaceMemberContract, } from '@/lib/api/contracts/v2/workspaces' import { @@ -112,6 +113,7 @@ import { workspaceMemberOperations, workspaceOperations, workspacePermissionOperations, + workspaceUpdateOperations, } from '@/lib/workspaces/application/operations' const BLOCK_SUMMARY_EXAMPLE = { @@ -604,6 +606,7 @@ const SERVICE_ACCOUNT_PROVIDER_EXAMPLE = { const CREDENTIAL_CONNECTION_EXAMPLE = { authorizationUrl: 'https://www.sim.ai/api/auth/oauth2/authorize?draftId=draft-123', + draftId: 'draft-123', expiresAt: '2026-06-20T14:17:11.000Z', } as const @@ -744,6 +747,40 @@ const declaredRoutes = [ ), } ), + defineOpenApiRoute( + v2UpdateWorkspaceContract, + resourceOperation('Workspaces', { + applicationOperation: workspaceUpdateOperations.updateIdentity, + operationId: 'updateWorkspace', + summary: 'Update workspace', + description: `Rename a workspace or change its logo. Omitted fields remain unchanged; send \`logoUrl: null\` to remove the logo. Requires workspace Admin access. ${WORKSPACE_API_KEY_DENIED}`, + errors: RESOURCE_ERRORS, + success: { description: 'The updated workspace.' }, + }), + { + params: documentedSchema( + v2UpdateWorkspaceContract.params, + 'UpdateWorkspaceParams', + 'Update workspace parameters', + 'Workspace selected for update.' + ), + query: v2UpdateWorkspaceContract.query, + body: documentedSchema( + v2UpdateWorkspaceContract.body, + 'UpdateWorkspaceBody', + 'Update workspace body', + 'The workspace fields to change.', + [{ name: 'Platform Engineering' }] + ), + response: documentedSchema( + v2UpdateWorkspaceContract.response.schema, + 'UpdateWorkspaceResponse', + 'Update workspace response', + 'The workspace after the update.', + [{ data: { ...WORKSPACE_EXAMPLE, name: 'Platform Engineering' } }] + ), + } + ), defineOpenApiRoute( v2ListWorkspaceMembersContract, resourceOperation('Workspaces', { @@ -1983,9 +2020,11 @@ const declaredRoutes = [ operationId: 'getApiMeta', summary: 'Get API status', description: - 'Get the calling credential’s type, expiry, and API scopes. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.', + 'Get the calling credential’s type, identity, expiry, and API scopes, plus links to the OpenAPI document, API docs, and Sim MCP server. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.', errors: META_ERRORS, - success: { description: 'Availability and lifecycle facts about the calling credential.' }, + success: { + description: 'Identity, availability, and lifecycle facts about the calling credential.', + }, }), { query: v2GetMetaContract.query, @@ -1993,7 +2032,7 @@ const declaredRoutes = [ v2GetMetaContract.response.schema, 'GetApiMetaResponse', 'API status response', - 'API availability, credential type, expiry, and scopes for the caller.', + 'API availability, credential type and identity, expiry, scopes, and discovery links for the caller.', [ { data: { @@ -2001,6 +2040,14 @@ const declaredRoutes = [ keyType: 'personal', expiresAt: null, scopes: ['api:read', 'api:write', 'search:read'], + keyId: 'key_7f3a9c2e', + userId: 'user-123', + workspaceId: null, + links: { + openapi: 'https://docs.sim.ai/openapi.json', + docs: 'https://docs.sim.ai/api-reference/getting-started', + mcp: 'https://mcp.sim.ai/mcp', + }, }, }, ] diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index 70e115098cb..3c67dbcf487 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -106,6 +106,42 @@ export const v2GetWorkspaceContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2WorkspaceSchema) }, }) +const v2UpdateWorkspaceParamsSchema = z + .object({ workspaceId: workspaceIdSchema.describe('Workspace to update.') }) + .strict() + +const v2UpdateWorkspaceBodySchema = z + .object({ + name: z + .string() + .trim() + .min(1, 'name cannot be empty') + .max(255, 'name must be at most 255 characters') + .optional() + .describe('New workspace display name.'), + logoUrl: z + .string() + .refine((value) => value.startsWith('/') || value.startsWith('https://'), { + message: 'logoUrl must be an absolute path or an HTTPS URL', + }) + .nullable() + .optional() + .describe('New workspace logo URL, or null to remove the logo.'), + }) + .strict() + .refine((body) => body.name !== undefined || body.logoUrl !== undefined, { + message: 'Provide name or logoUrl', + }) + +export const v2UpdateWorkspaceContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/workspaces/[workspaceId]', + query: noInputSchema, + params: v2UpdateWorkspaceParamsSchema, + body: v2UpdateWorkspaceBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2WorkspaceSchema) }, +}) + export const v2ListWorkspacesContract = defineRouteContract({ method: 'GET', path: '/api/v2/workspaces', diff --git a/apps/sim/lib/api/links.ts b/apps/sim/lib/api/links.ts new file mode 100644 index 00000000000..973d4f4eaeb --- /dev/null +++ b/apps/sim/lib/api/links.ts @@ -0,0 +1,21 @@ +import { getSimMcpUrl } from '@/lib/api/mcp/urls' +import { DOCS_URL } from '@/lib/help-links' + +/** Where a v2 API client finds the machine-readable contract, the reference docs, and the MCP server. */ +export interface V2ApiLinks { + openapi: string + docs: string + mcp: string +} + +/** + * This deployment's v2 discovery links. The OpenAPI document and reference docs + * are the published ones; the MCP URL is this deployment's own server. + */ +export function getV2ApiLinks(): V2ApiLinks { + return { + openapi: `${DOCS_URL}/openapi.json`, + docs: `${DOCS_URL}/api-reference/getting-started`, + mcp: getSimMcpUrl(), + } +} diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 7984fbe8faa..c03d56a246f 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -361,6 +361,7 @@ import { v2ListWorkspaceMembersContract, v2ListWorkspacesContract, v2RemoveWorkspaceMemberContract, + v2UpdateWorkspaceContract, v2UpdateWorkspaceMemberContract, } from '@/lib/api/contracts/v2/workspaces' import type { V2McpOperation } from '@/lib/api/mcp/types' @@ -1418,7 +1419,7 @@ export const V2_MCP_OPERATIONS = { contract: v2GetMetaContract, summary: 'Get API status', description: - 'Get the calling credential’s type, expiry, and API scopes. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.', + 'Get the calling credential’s type, identity, expiry, and API scopes, plus links to the OpenAPI document, API docs, and Sim MCP server. Requires a valid API key or OAuth access token. `v2Enabled` is true whenever this endpoint is served; the response does not enumerate resource permissions.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/meta/route').then((route) => route.GET), }, getNextKnowledgeTagSlot: { @@ -3031,6 +3032,15 @@ export const V2_MCP_OPERATIONS = { (route) => route.PATCH ), }, + updateWorkspace: { + contract: v2UpdateWorkspaceContract, + summary: 'Update workspace', + description: + 'Rename a workspace or change its logo. Omitted fields remain unchanged; send `logoUrl: null` to remove the logo. Requires workspace Admin access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/workspaces/[workspaceId]/route').then((route) => route.PATCH), + }, updateWorkspaceForkExclusions: { contract: v2UpdateWorkspaceForkExclusionsContract, summary: 'Update workspace fork exclusions', diff --git a/apps/sim/lib/cli-auth/approval-store.test.ts b/apps/sim/lib/cli-auth/approval-store.test.ts index 52fbb80777a..d365c0f8fde 100644 --- a/apps/sim/lib/cli-auth/approval-store.test.ts +++ b/apps/sim/lib/cli-auth/approval-store.test.ts @@ -57,6 +57,8 @@ describe('cli-auth approval store', () => { scope: 'copilot', workspaceId: null, workspaceBound: false, + access: 'write', + expiresInDays: null, }) // NX lock on the mint key; TTL matches the approval so they expire together // and a failed cleanup can't leave a re-mintable window. Record not deleted here. diff --git a/apps/sim/lib/cli-auth/approval-store.ts b/apps/sim/lib/cli-auth/approval-store.ts index 7b07fe8cd19..6b95f3d42d0 100644 --- a/apps/sim/lib/cli-auth/approval-store.ts +++ b/apps/sim/lib/cli-auth/approval-store.ts @@ -1,3 +1,4 @@ +import type { ApiKeyAccess } from '@sim/auth/principal' import { safeCompare } from '@sim/security/compare' import { sha256Base64Url, sha256Hex } from '@sim/security/hash' import type { CliAuthScope } from '@/lib/api/contracts/cli-auth' @@ -42,6 +43,10 @@ interface ApprovalRecord { workspaceId?: string /** Whether to mint a key scoped to `workspaceId`. Admin-verified at approval. */ workspaceBound?: boolean + /** Platform scope only: the approved access. Absent on older records, which mint full access. */ + access?: ApiKeyAccess + /** Platform scope only: the approved lifetime in days. Absent means the key never expires. */ + expiresInDays?: number createdAt: number } @@ -50,6 +55,8 @@ export interface ApprovalGrant { scope: CliAuthScope workspaceId: string | null workspaceBound: boolean + access: ApiKeyAccess + expiresInDays: number | null } export type PollResult = { status: 'pending' } | ({ status: 'approved' } & ApprovalGrant) @@ -82,7 +89,13 @@ export async function createApproval( userId: string, requestId: string, challenge: string, - grant: { scope: CliAuthScope; workspaceId?: string; workspaceBound?: boolean } = { + grant: { + scope: CliAuthScope + workspaceId?: string + workspaceBound?: boolean + access?: ApiKeyAccess + expiresInDays?: number + } = { scope: 'copilot', } ): Promise { @@ -94,6 +107,8 @@ export async function createApproval( ...(grant.workspaceId ? { workspaceId: grant.workspaceId, workspaceBound: grant.workspaceBound === true } : {}), + ...(grant.access ? { access: grant.access } : {}), + ...(grant.expiresInDays ? { expiresInDays: grant.expiresInDays } : {}), createdAt: Date.now(), } await redis.set(approvalKey(requestId), JSON.stringify(record), 'PX', APPROVAL_TTL_MS) @@ -134,6 +149,8 @@ export async function pollApproval(requestId: string, pollSecret: string): Promi scope: record.scope ?? 'copilot', workspaceId: record.workspaceId ?? null, workspaceBound: record.workspaceBound === true, + access: record.access ?? 'write', + expiresInDays: record.expiresInDays ?? null, } } diff --git a/apps/sim/lib/credentials/draft-constants.ts b/apps/sim/lib/credentials/draft-constants.ts index b07a7ec85d6..f14434cf001 100644 --- a/apps/sim/lib/credentials/draft-constants.ts +++ b/apps/sim/lib/credentials/draft-constants.ts @@ -7,3 +7,9 @@ export const OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM = 'credentialDraftId' * message to show. */ export const OAUTH_CREDENTIAL_ACCOUNT_CONFLICT_ERROR = 'credential_account_conflict' +/** + * `error` code for a connection link the signed-in user cannot open: unknown, + * expired, or created by another account. Deliberately one code for all three, + * so the response never says whether the link exists or whose it is. + */ +export const OAUTH_LINK_UNAVAILABLE_ERROR = 'oauth_link_unavailable' diff --git a/apps/sim/lib/workspaces/application/operations.ts b/apps/sim/lib/workspaces/application/operations.ts index 118f382157e..d4a4476786a 100644 --- a/apps/sim/lib/workspaces/application/operations.ts +++ b/apps/sim/lib/workspaces/application/operations.ts @@ -77,3 +77,43 @@ export const workspaceMemberOperations = { delegatedServices: ['copilot'], }), } as const + +export const workspaceUpdateOperations = { + // permission-group-exempt: workspace administration has no module capability; the admin role is the gate, as it was before this operation existed + update: defineWorkspaceOperation({ + id: 'workspaces.update', + minimumRole: 'admin', + capability: 'none', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }), + // permission-group-exempt: reading the workspace's own name and logo, which every member already sees in the workspace switcher + readIdentity: defineWorkspaceOperation({ + id: 'workspaces.identity.read', + minimumRole: 'read', + capability: 'none', + contentDelivery: 'none', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], + delegatedServices: ['copilot'], + }), + /** + * Name and logo only. Billing payer and the personal-key policy stay on + * {@link workspaceUpdateOperations.update}, which only a signed-in session + * reaches; this narrower operation is the one Copilot and the public API get. + * + * Workspace API keys stay denied: renaming needs Admin, above a workspace + * key's write ceiling. + */ + // permission-group-exempt: same admin-role gate as the full workspace update, narrowed to presentation fields + updateIdentity: defineWorkspaceOperation({ + id: 'workspaces.identity.update', + minimumRole: 'admin', + capability: 'none', + contentDelivery: 'none', + workspaceApiKey: 'deny', + oauthScope: 'api:write', + principalKinds: ['personal_api_key', 'oauth_access_token', 'delegated'], + delegatedServices: ['copilot'], + }), +} as const diff --git a/apps/sim/lib/workspaces/application/update-workspace.ts b/apps/sim/lib/workspaces/application/update-workspace.ts index 501efe92b3e..43368871bf8 100644 --- a/apps/sim/lib/workspaces/application/update-workspace.ts +++ b/apps/sim/lib/workspaces/application/update-workspace.ts @@ -6,12 +6,13 @@ import { NoWorkspaceAccessError, type WorkspaceUseCaseAuditEntry, } from '@/lib/core/application' -import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' import { loadActiveWorkspaceApplicationContext, resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/context' +import { workspaceUpdateOperations } from '@/lib/workspaces/application/operations' +import { countPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' import { applyWorkspaceUpdate, readWorkspaceIdentityRecord, @@ -20,41 +21,6 @@ import { type WorkspaceUpdateResult, } from '@/lib/workspaces/update' -export const workspaceUpdateOperations = { - // permission-group-exempt: workspace administration has no module capability; the admin role is the gate, as it was before this operation existed - update: defineWorkspaceOperation({ - id: 'workspaces.update', - minimumRole: 'admin', - capability: 'none', - workspaceApiKey: 'deny', - principalKinds: ['session'], - }), - // permission-group-exempt: reading the workspace's own name and logo, which every member already sees in the workspace switcher - readIdentity: defineWorkspaceOperation({ - id: 'workspaces.identity.read', - minimumRole: 'read', - capability: 'none', - contentDelivery: 'none', - workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], - }), - /** - * Name and logo only. Billing payer and the personal-key policy stay on - * {@link workspaceUpdateOperations.update}, which Copilot cannot reach. - */ - // permission-group-exempt: same admin-role gate as the full workspace update, narrowed to presentation fields - updateIdentity: defineWorkspaceOperation({ - id: 'workspaces.identity.update', - minimumRole: 'admin', - capability: 'none', - contentDelivery: 'none', - workspaceApiKey: 'deny', - principalKinds: ['delegated'], - delegatedServices: ['copilot'], - }), -} as const - export const workspaceIdentityPatchSchema = updateWorkspaceBodySchema .pick({ name: true, logoUrl: true }) .strict() @@ -132,8 +98,20 @@ export const updateWorkspaceIdentity = defineAuthorizedWorkspaceUseCase({ const parsed = workspaceIdentityPatchSchema.safeParse(input.patch) if (!parsed.success) throw new OrchestrationError('validation', parsed.error.issues[0].message) const patch = { name: parsed.data.name, logoUrl: parsed.data.logoUrl } - const result = await applyWorkspaceUpdate(context.workspaceId, patch) - return { ...result, patch, identity: presentIdentity(result.updated) } + const [result, memberCount] = await Promise.all([ + applyWorkspaceUpdate(context.workspaceId, patch), + countPublicWorkspaceMembers({ + id: context.workspaceId, + organizationId: context.workspaceOrganizationId, + }), + ]) + const { id, name, color, logoUrl, createdAt, updatedAt } = result.updated + return { + ...result, + patch, + identity: presentIdentity(result.updated), + workspace: { id, name, color, logoUrl, memberCount, createdAt, updatedAt }, + } }, projectAudit: ({ result }) => projectUpdateAudit(result.patch, result), }) diff --git a/apps/sim/lib/workspaces/public-queries.ts b/apps/sim/lib/workspaces/public-queries.ts index 68dd082d10b..e34192cde42 100644 --- a/apps/sim/lib/workspaces/public-queries.ts +++ b/apps/sim/lib/workspaces/public-queries.ts @@ -126,6 +126,16 @@ export async function getPublicWorkspaceDetails( return details } +/** Effective member count of one workspace, counted the way the public detail reports it. */ +export async function countPublicWorkspaceMembers(row: { + id: string + organizationId: string | null +}): Promise { + const count = (await countWorkspaceMembers([row])).get(row.id) + if (count === undefined) throw new Error(`Invalid member count for workspace ${row.id}`) + return count +} + export async function getPublicWorkspaceDetail( workspaceId: string ): Promise { diff --git a/packages/sim-cli/src/auth/device-flow.ts b/packages/sim-cli/src/auth/device-flow.ts index 9a4465ad756..c7e35890a56 100644 --- a/packages/sim-cli/src/auth/device-flow.ts +++ b/packages/sim-cli/src/auth/device-flow.ts @@ -77,6 +77,22 @@ export interface MintedKey { workspaceId: string | null /** Whether the key can *only* reach that workspace. */ workspaceBound: boolean + /** + * The access the key was minted with, or null from a server that predates + * read-only handoff keys — which mints full access whatever was asked. + */ + access: KeyAccess | null + /** When the key expires; null when it never does or the server did not say. */ + expiresAt: string | null +} + +type KeyAccess = 'read' | 'write' + +/** What the terminal asks the minted key to be; the browser approval decides. */ +export interface KeyRequest { + readOnly: boolean + /** Whole days until the key expires; undefined for a key that never expires. */ + expiresInDays?: number } /** 32 bytes of entropy, base64url — 43 characters, exactly what the contract accepts. */ @@ -105,7 +121,8 @@ export function createAuthRequest(): AuthRequest { export function buildApprovalUrl( endpoint: string, auth: AuthRequest, - workspaceId?: string + workspaceId?: string, + keyRequest: KeyRequest = { readOnly: false } ): string { return buildUrl(endpoint, APPROVAL_PATH, { request: auth.request, @@ -113,6 +130,8 @@ export function buildApprovalUrl( pairing: auth.pairing, scope: 'platform', workspace: workspaceId, + access: keyRequest.readOnly ? 'read' : undefined, + expires: keyRequest.expiresInDays, }) } @@ -122,6 +141,8 @@ interface PollResponse { scope?: CliAuthScope workspaceId?: string | null workspaceBound?: boolean + access?: KeyAccess + expiresAt?: string | null } /** @@ -245,6 +266,8 @@ export async function pollForKey( scope: body.scope ?? 'copilot', workspaceId: body.workspaceId ?? null, workspaceBound: body.workspaceBound === true, + access: body.access === 'read' || body.access === 'write' ? body.access : null, + expiresAt: typeof body.expiresAt === 'string' ? body.expiresAt : null, } } } diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 477b3befef8..79d69f6fb6e 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -33,6 +33,8 @@ const mocks = vi.hoisted(() => ({ scope: 'platform' | 'copilot' workspaceBound?: boolean workspaceId?: string + access?: 'read' | 'write' | null + expiresAt?: string | null }> >(async () => ({ id: 'key-id', @@ -329,6 +331,26 @@ describe('login command', () => { expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() }) + it('stores nothing when a read-only request comes back as a full-access key', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + id: 'key-id', + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + access: null, + expiresAt: null, + }) + + await expect(login('--read-only')).rejects.toThrow('not the read-only key requested') + + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( + 'API key key-id was created but could not be stored safely' + ) + }) + it('stores nothing when the server answers with a malformed key', async () => { setInteractive(false) mocks.pollForKey.mockResolvedValue({ diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 28c2cdf0c84..6633f90aed9 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -6,7 +6,13 @@ import { toRecord } from '@sim/utils/object' import { Command, Option } from 'commander' import { printLine } from '#sim-cli/output/io' import { styles } from '#sim-cli/output/presentation' -import { buildApprovalUrl, createAuthRequest, pollForKey } from '../auth/device-flow' +import { + buildApprovalUrl, + createAuthRequest, + type KeyRequest, + type MintedKey, + pollForKey, +} from '../auth/device-flow' import { discoverOAuthProvider, grantsWriteAccess, @@ -369,10 +375,27 @@ interface LoginOptions { browser: boolean method?: 'oauth' | 'api-key' readOnly?: boolean + expiresIn?: string callbackPort?: string yes?: boolean } +/** The lifetime bounds the server enforces on every API key, in whole days. */ +const MIN_KEY_EXPIRY_DAYS = 1 +const MAX_KEY_EXPIRY_DAYS = 365 + +function parseExpiresIn(value: string | undefined): number | undefined { + if (value === undefined) return undefined + const days = Number(value) + if (!/^\d+$/.test(value.trim()) || days < MIN_KEY_EXPIRY_DAYS || days > MAX_KEY_EXPIRY_DAYS) { + throw new SimApiError( + `Invalid --expires-in "${redact(value)}". Use a whole number of days from ${MIN_KEY_EXPIRY_DAYS} to ${MAX_KEY_EXPIRY_DAYS}.`, + 0 + ) + } + return days +} + /** * Which login to run. * @@ -539,11 +562,15 @@ export function loginCommand(): Command { .addOption( new Option( '--method ', - 'Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted)' + 'Credential to obtain: oauth requires OAuth support; api-key creates an API key through pairing (auto-selects when omitted)' ).choices(['oauth', 'api-key']) ) .option('--no-browser', 'Print the approval URL without opening it (either login method)') .option('--read-only', 'Ask only for permission to read, never to change anything') + .option( + '--expires-in ', + `API-key login only: days until the key expires (${MIN_KEY_EXPIRY_DAYS}-${MAX_KEY_EXPIRY_DAYS}); omit for a key that never expires` + ) .option('--callback-port ', 'Pin the local port the browser returns to') .option('-y, --yes', 'Overwrite an existing API-key profile without prompting') .action(async (options: LoginOptions, command: Command) => { @@ -574,30 +601,27 @@ export function loginCommand(): Command { } } - /** Validate a pinned port before opening a browser or starting either flow. */ + /** Validate pinned values before opening a browser or starting either flow. */ const callbackPort = parseCallbackPort(options.callbackPort) + const expiresInDays = parseExpiresIn(options.expiresIn) const loginFlow = await chooseLoginFlow(profile, options) /** - * Neither flag has a meaning in the handoff: it mints a permanent, - * full-power API key on the server and never opens a local listener. - * Honouring `--read-only` by ignoring it would hand back the opposite of - * what was asked for, so the whole login stops here rather than storing a - * credential the person did not agree to. + * Each of these flags means something in only one flow. Ignoring one would + * store a credential other than the one asked for, so the whole login stops + * here instead. */ - if (loginFlow === 'handoff') { - if (options.readOnly) { - throw new SimApiError( - 'API-key login cannot issue a read-only login. Use --method oauth, or drop --read-only.', - 0 - ) - } - if (callbackPort !== undefined) { - throw new SimApiError( - 'API-key login has no local callback, so --callback-port does not apply. Use --method oauth, or drop --callback-port.', - 0 - ) - } + if (loginFlow === 'handoff' && callbackPort !== undefined) { + throw new SimApiError( + 'API-key login has no local callback, so --callback-port does not apply. Use --method oauth, or drop --callback-port.', + 0 + ) + } + if (loginFlow === 'oauth' && expiresInDays !== undefined) { + throw new SimApiError( + 'OAuth logins renew themselves, so --expires-in does not apply. Use --method api-key, or drop --expires-in.', + 0 + ) } await withProfileLoginLease(profile.name, async () => { assertLoginProfileUnchanged(profile.name, snapshot) @@ -605,19 +629,48 @@ export function loginCommand(): Command { await loginWithOAuth(profile, options, callbackPort, snapshot) return } - await loginWithHandoff(profile, options, snapshot) + await loginWithHandoff( + profile, + options, + { readOnly: options.readOnly === true, expiresInDays }, + snapshot + ) }) }) } -/** The pairing-code handoff, which mints a permanent API key; see `device-flow.ts`. */ +/** + * Refuses a minted key that is not the one requested. + * + * The approval decides what is minted, and a server that predates read-only + * and expiring handoff keys ignores the request and mints a full-access key + * that never expires. Storing that after `--read-only` would hand back the + * opposite of what was asked for. + */ +function requireRequestedKey(key: MintedKey, keyRequest: KeyRequest): void { + if (keyRequest.readOnly && key.access !== 'read') { + throw new SimApiError( + 'The server issued a full-access key, not the read-only key requested. Nothing was stored; update the Sim deployment or drop --read-only.', + 0 + ) + } + if (keyRequest.expiresInDays !== undefined && !key.expiresAt) { + throw new SimApiError( + 'The server issued a key that never expires, not one with the requested expiry. Nothing was stored; update the Sim deployment or drop --expires-in.', + 0 + ) + } +} + +/** The pairing-code handoff, which mints an API key; see `device-flow.ts`. */ async function loginWithHandoff( profile: ResolvedProfile, options: LoginOptions, + keyRequest: KeyRequest, expected: LoginProfileSnapshot ): Promise { const auth = createAuthRequest() - const url = buildApprovalUrl(profile.endpoint, auth, profile.workspaceId ?? undefined) + const url = buildApprovalUrl(profile.endpoint, auth, profile.workspaceId ?? undefined, keyRequest) printLine( `Signing in to ${styles().bold(profile.endpoint)} as profile ${styles().bold(safeOneLine(profile.name))}` @@ -637,6 +690,7 @@ async function loginWithHandoff( 0 ) } + requireRequestedKey(key, keyRequest) const settings: Record = { endpoint: profile.endpoint, @@ -684,6 +738,11 @@ async function loginWithHandoff( } printLine(styles().green(`\n✓ Logged in. Key stored in ${credentialsPath()}`)) + printLine( + styles().dim( + ` ${key.access === 'read' ? 'Read-only key — commands that change anything will be refused.' : 'Full-access key.'} ${key.expiresAt ? `Expires ${safeOneLine(key.expiresAt)}.` : 'Never expires.'}` + ) + ) if (key.workspaceBound && key.workspaceId) { printLine(styles().dim(` Workspace-scoped key — it can only reach ${key.workspaceId}.`)) } else if (key.workspaceId) { @@ -823,17 +882,46 @@ interface VerifiedWorkspace { * missing workspace needs `sim configure`, and an unreachable endpoint needs * neither. */ -type Verification = { keyType: KeyType | null; authenticated: boolean | null } & ( - | { status: 'verified'; workspace: VerifiedWorkspace; detail: null } - | { - status: 'rejected' | 'unreachable' | 'unauthenticated' | 'no-workspace' | 'disabled' - workspace: null - detail: string - } -) +type Verification = CredentialFacts & { authenticated: boolean | null } & ( + | { status: 'verified'; workspace: VerifiedWorkspace; detail: null } + | { + status: 'rejected' | 'unreachable' | 'unauthenticated' | 'no-workspace' | 'disabled' + workspace: null + detail: string + } + ) type KeyType = GetMetaResponse['data']['keyType'] +/** What the API reports about the calling credential itself, when it could be asked. */ +interface CredentialFacts { + keyType: KeyType | null + /** When the credential expires; null when it never does or could not be read. */ + expiresAt: string | null + /** The only workspace a workspace-scoped key can reach; null for any other credential. */ + boundWorkspaceId: string | null +} + +const UNKNOWN_CREDENTIAL: CredentialFacts = { + keyType: null, + expiresAt: null, + boundWorkspaceId: null, +} + +/** Reads the credential facts out of a metadata response, tolerating an older server's shape. */ +function credentialFactsFrom(response: unknown): CredentialFacts | null { + const data = toRecord(toRecord(response).data) + const { keyType, expiresAt, workspaceId } = data + if (keyType !== 'personal' && keyType !== 'workspace' && keyType !== 'oauth_access_token') { + return null + } + return { + keyType, + expiresAt: typeof expiresAt === 'string' ? expiresAt : null, + boundWorkspaceId: typeof workspaceId === 'string' ? workspaceId : null, + } +} + /** Definitive profile refusals, including a workspace the credential cannot access. */ const PROFILE_REJECTION_STATUSES = new Set([401, 403, 404]) @@ -873,22 +961,17 @@ async function verifyProfile( status: 'unauthenticated', authenticated: false, workspace: null, - keyType: null, + ...UNKNOWN_CREDENTIAL, detail: `not logged in — run: sim login --profile ${safeOneLine(profile.name)}`, } } - let keyType: KeyType | null = null + let facts = UNKNOWN_CREDENTIAL let authenticated: boolean | null = null try { - const response = await client.request('getMeta') - const reportedKeyType = toRecord(toRecord(response).data).keyType - if ( - reportedKeyType === 'personal' || - reportedKeyType === 'workspace' || - reportedKeyType === 'oauth_access_token' - ) { - keyType = reportedKeyType + const reported = credentialFactsFrom(await client.request('getMeta')) + if (reported) { + facts = reported authenticated = true } } catch (error) { @@ -898,7 +981,7 @@ async function verifyProfile( status: 'rejected', authenticated: false, workspace: null, - keyType: null, + ...UNKNOWN_CREDENTIAL, detail: error.message, } } @@ -909,7 +992,7 @@ async function verifyProfile( status: 'no-workspace', authenticated, workspace: null, - keyType, + ...facts, detail: `no workspace to check against — run: sim configure --profile ${safeOneLine(profile.name)} --set-workspace `, } } @@ -925,7 +1008,7 @@ async function verifyProfile( status: 'verified', authenticated: true, workspace: { id, name, memberCount }, - keyType, + ...facts, detail: null, } } catch (error) { @@ -934,7 +1017,7 @@ async function verifyProfile( status: PROFILE_REJECTION_STATUSES.has(error.status) ? 'rejected' : 'unreachable', authenticated: error.status === 401 ? false : authenticated, workspace: null, - keyType, + ...facts, detail: error.message, } } @@ -978,7 +1061,7 @@ export function whoamiCommand(): Command { status: 'disabled', authenticated: authentication.configured ? null : false, workspace: null, - keyType: null, + ...UNKNOWN_CREDENTIAL, detail: 'not checked (--no-verify)', } @@ -1001,6 +1084,17 @@ export function whoamiCommand(): Command { verification.keyType ?? styles().dim(options.verify ? 'unknown' : 'not checked (--no-verify)'), ], + [ + 'Key expires', + verification.keyType + ? verification.expiresAt + ? safeOneLine(verification.expiresAt) + : 'never' + : styles().dim(options.verify ? 'unknown' : 'not checked (--no-verify)'), + ], + ...(verification.boundWorkspaceId + ? [['Key bound to', safeOneLine(verification.boundWorkspaceId)] as [string, string]] + : []), ['Workspace', annotate(profile.workspaceId ?? '', sources.workspaceId)], ['Output', annotate(profile.output, sources.output)], ['Verified', presentVerification(verification)], @@ -1021,6 +1115,8 @@ export function whoamiCommand(): Command { status: verification.status, workspace: verification.workspace, keyType: verification.keyType, + expiresAt: verification.expiresAt, + boundWorkspaceId: verification.boundWorkspaceId, detail: verification.detail, }, } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index a76ee9d6a01..0a2aa999430 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -20,6 +20,16 @@ const ACCESS_REQUEST_COLUMNS: ColumnSpec[] = [ { header: 'created', path: 'createdAt', format: 'timestamp' }, ] +const WORKSPACE_FIELDS: ColumnSpec[] = [ + { header: 'id' }, + { header: 'name' }, + { header: 'color' }, + { header: 'logo', path: 'logoUrl' }, + { header: 'members', path: 'memberCount' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, +] + const FILE_SHARE_FIELDS: ColumnSpec[] = [ { header: 'shared', path: 'isActive', format: 'bool' }, { header: 'URL', path: 'url' }, @@ -1523,15 +1533,11 @@ export const CLI_CONTRACT: CliContract = { // `mode` is not a field of the strict v2 workspace schema, so it rendered // an em-dash on every call; `color` and `logoUrl` are returned and were the // two the record left out. - fields: [ - { header: 'id' }, - { header: 'name' }, - { header: 'color' }, - { header: 'logo', path: 'logoUrl' }, - { header: 'members', path: 'memberCount' }, - { header: 'created', path: 'createdAt', format: 'timestamp' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - ], + fields: WORKSPACE_FIELDS, + }, + updateWorkspace: { + profileWorkspacePath: true, + fields: WORKSPACE_FIELDS, }, listWorkspaceMembers: { command: 'workspaces members list', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 052210e9390..971ffba68a9 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -2060,6 +2060,7 @@ export type CreateCredentialConnectionBody = type CreateCredentialConnectionResponseRef0 = { authorizationUrl: string + draftId: string expiresAt: string } @@ -6663,6 +6664,14 @@ type GetMetaResponseRef0 = { keyType: 'personal' | 'workspace' | 'oauth_access_token' expiresAt: string | null scopes: Array<'api:read' | 'api:write' | 'search:read'> + keyId: string | null + userId: string | null + workspaceId: string | null + links: { + openapi: string + docs: string + mcp: string + } } export type GetMetaResponse = { @@ -15946,6 +15955,32 @@ export type UpdateWorkflowVersionResponse = { data: UpdateWorkflowVersionResponseRef0 } +/** `PATCH /api/v2/workspaces/[workspaceId]` */ +export type UpdateWorkspaceParams = { + workspaceId: string +} + +export type UpdateWorkspaceQuery = Record + +export type UpdateWorkspaceBody = { + name?: string + logoUrl?: string | null +} + +type UpdateWorkspaceResponseRef0 = { + id: string + name: string + color: string + logoUrl: string | null + memberCount: number + createdAt: string + updatedAt: string +} + +export type UpdateWorkspaceResponse = { + data: UpdateWorkspaceResponseRef0 +} + /** `PUT /api/v2/workspaces/[workspaceId]/fork/exclusions` */ export type UpdateWorkspaceForkExclusionsParams = { workspaceId: string @@ -25245,6 +25280,19 @@ export const V2_OPERATIONS = { }, }, }, + updateWorkspace: { + method: 'PATCH', + path: '/api/v2/workspaces/[workspaceId]', + pathParams: ['workspaceId'] as const, + pathParamDocs: { workspaceId: 'Workspace to update.' }, + responseMode: 'json', + summary: 'Update workspace', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'New workspace display name.' }, + logoUrl: { kind: 'string', describe: 'New workspace logo URL, or null to remove the logo.' }, + }, + }, updateWorkspaceForkExclusions: { method: 'PUT', path: '/api/v2/workspaces/[workspaceId]/fork/exclusions', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 0ab8d207b65..c44b287324b 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -447,6 +447,7 @@ describe('destructive operations are gated', () => { 'createOrganizationInvitation', 'resendOrganizationInvitation', 'updateOrganizationMember', + 'updateWorkspace', 'updateWorkspaceMember', 'createPermissionGroup', 'updatePermissionGroup', diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts index 6b5320089ef..7377665c787 100644 --- a/scripts/generate-cli-docs.ts +++ b/scripts/generate-cli-docs.ts @@ -414,7 +414,7 @@ const GROUP_SECTIONS: Record = { workspaces: [ { title: 'Workspace access', - prefixes: ['list', 'get', 'members', 'invitations', 'permission-config'], + prefixes: ['list', 'get', 'update', 'members', 'invitations', 'permission-config'], }, { title: 'Access requests', From 718e6f5e44a0e82771e4b642f8e200aec0f137b5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 13:57:42 -0800 Subject: [PATCH 2/6] fix(cli-auth): pass route context in the new approve and poll tests --- apps/sim/app/api/cli/auth/approve/route.test.ts | 3 ++- apps/sim/app/api/cli/auth/poll/route.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/cli/auth/approve/route.test.ts b/apps/sim/app/api/cli/auth/approve/route.test.ts index 5671c2f2c01..737efc11b43 100644 --- a/apps/sim/app/api/cli/auth/approve/route.test.ts +++ b/apps/sim/app/api/cli/auth/approve/route.test.ts @@ -118,7 +118,8 @@ describe('POST /api/cli/auth/approve', () => { challenge: CHALLENGE, scope: 'copilot', access: 'read', - }) + }), + { params: Promise.resolve({}) } ) expect(response.status).toBe(400) expect(mockCreateApproval).not.toHaveBeenCalled() diff --git a/apps/sim/app/api/cli/auth/poll/route.test.ts b/apps/sim/app/api/cli/auth/poll/route.test.ts index 9cceb087ff5..271ca808164 100644 --- a/apps/sim/app/api/cli/auth/poll/route.test.ts +++ b/apps/sim/app/api/cli/auth/poll/route.test.ts @@ -121,7 +121,8 @@ describe('POST /api/cli/auth/poll', () => { ) const before = Date.now() const response = await POST( - pollRequest({ request: REQUEST, verifier: VERIFIER, access: 'write', expiresInDays: null }) + pollRequest({ request: REQUEST, verifier: VERIFIER, access: 'write', expiresInDays: null }), + { params: Promise.resolve({}) } ) expect(response.status).toBe(200) const [params] = mockCreatePersonalApiKey.mock.calls[0] From 8d8e6187cae60d664ddc8d84dc1882f32dae8261 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:01:29 -0800 Subject: [PATCH 3/6] test(mcp): expect the identity fields getMeta now returns --- apps/sim/lib/api/mcp/route-handler.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/api/mcp/route-handler.test.ts b/apps/sim/lib/api/mcp/route-handler.test.ts index dfe2c9d92e1..36501458177 100644 --- a/apps/sim/lib/api/mcp/route-handler.test.ts +++ b/apps/sim/lib/api/mcp/route-handler.test.ts @@ -97,12 +97,15 @@ describe('Sim MCP tools', () => { it('serves a read through the v2 route with the MCP credential and audience', async () => { const result = await callTool('call_read_operation', { operation: 'getMeta' }) expect(result.isError).toBeFalsy() - expect(JSON.parse(result.content[0].text)).toEqual({ + expect(JSON.parse(result.content[0].text)).toMatchObject({ data: { v2Enabled: true, keyType: 'personal', expiresAt: null, scopes: ['api:read', 'api:write', 'search:read'], + keyId: 'key-1', + userId: 'user-1', + workspaceId: null, }, }) expect(v2RouteMocks.authenticate).toHaveBeenCalledTimes(2) From 5f3d4b659d0edd14bead60f4df336ce81ae7021d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:02:08 -0800 Subject: [PATCH 4/6] fix(workspaces): count members before the rename so a failed count never hides a committed update --- .../workspaces/application/update-workspace.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/workspaces/application/update-workspace.ts b/apps/sim/lib/workspaces/application/update-workspace.ts index 43368871bf8..82b501d4f9f 100644 --- a/apps/sim/lib/workspaces/application/update-workspace.ts +++ b/apps/sim/lib/workspaces/application/update-workspace.ts @@ -98,13 +98,14 @@ export const updateWorkspaceIdentity = defineAuthorizedWorkspaceUseCase({ const parsed = workspaceIdentityPatchSchema.safeParse(input.patch) if (!parsed.success) throw new OrchestrationError('validation', parsed.error.issues[0].message) const patch = { name: parsed.data.name, logoUrl: parsed.data.logoUrl } - const [result, memberCount] = await Promise.all([ - applyWorkspaceUpdate(context.workspaceId, patch), - countPublicWorkspaceMembers({ - id: context.workspaceId, - organizationId: context.workspaceOrganizationId, - }), - ]) + // Counted before the write, never after or alongside it: a failed count + // after the commit would report a persisted rename as failed and skip its + // audit. A rename does not change membership, so the earlier count holds. + const memberCount = await countPublicWorkspaceMembers({ + id: context.workspaceId, + organizationId: context.workspaceOrganizationId, + }) + const result = await applyWorkspaceUpdate(context.workspaceId, patch) const { id, name, color, logoUrl, createdAt, updatedAt } = result.updated return { ...result, From e72693b2ba2ad7c419db27b5e75819e16edfb945 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:09:50 -0800 Subject: [PATCH 5/6] improvement(cli-auth): reuse the api-key access schema and tidy handoff validation --- apps/sim/app/api/cli/auth/approve/route.ts | 5 +++-- apps/sim/app/api/cli/auth/poll/route.ts | 3 ++- apps/sim/app/cli/auth/cli-auth-request.ts | 14 ++++++-------- apps/sim/lib/api/contracts/api-keys.ts | 2 +- apps/sim/lib/api/contracts/cli-auth.ts | 8 +++----- .../workspaces/application/update-workspace.ts | 15 +++++++++++++-- 6 files changed, 28 insertions(+), 19 deletions(-) diff --git a/apps/sim/app/api/cli/auth/approve/route.ts b/apps/sim/app/api/cli/auth/approve/route.ts index fa0015aa831..81941671e56 100644 --- a/apps/sim/app/api/cli/auth/approve/route.ts +++ b/apps/sim/app/api/cli/auth/approve/route.ts @@ -28,8 +28,9 @@ const logger = createLogger('CliAuthApproveAPI') * * That makes the approval record the sole carrier of the decision. The poll body * is a request id and a secret and nothing else, so it cannot assert a scope, a - * workspace, a binding, an access level, or a lifetime of its own — a refusal here writes no record, and a - * poll driven directly against the same request id answers `pending` forever. + * workspace, a binding, an access level, or a lifetime of its own — a refusal + * here writes no record, and a poll driven directly against the same request id + * answers `pending` forever. */ export const POST = withRouteHandler(async (request: NextRequest) => { const session = await getSession() diff --git a/apps/sim/app/api/cli/auth/poll/route.ts b/apps/sim/app/api/cli/auth/poll/route.ts index d8a20117828..f55b0056e19 100644 --- a/apps/sim/app/api/cli/auth/poll/route.ts +++ b/apps/sim/app/api/cli/auth/poll/route.ts @@ -1,3 +1,4 @@ +import type { ApiKeyAccess } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { pollCliAuthContract } from '@/lib/api/contracts' @@ -67,7 +68,7 @@ async function mintForGrant(grant: ApprovalGrant): Promise< | { ok: true key: { id: string; apiKey: string } - settings?: { access: 'read' | 'write'; expiresAt: string | null } + settings?: { access: ApiKeyAccess; expiresAt: string | null } } | { ok: false; status: number; message: string } > { diff --git a/apps/sim/app/cli/auth/cli-auth-request.ts b/apps/sim/app/cli/auth/cli-auth-request.ts index 33ee2f5431f..8331cc47775 100644 --- a/apps/sim/app/cli/auth/cli-auth-request.ts +++ b/apps/sim/app/cli/auth/cli-auth-request.ts @@ -72,20 +72,18 @@ export function resolveCliAuthRequest({ return { valid: false, reason: 'Key access and expiry only apply to Sim API keys.' } } - const keyAccess: ApiKeyAccess | null = - access === null ? 'write' : access === 'read' || access === 'write' ? access : null - if (!keyAccess) { + const keyAccess = access ?? 'write' + if (keyAccess !== 'read' && keyAccess !== 'write') { return { valid: false, reason: 'The requested key access is malformed.' } } const expiresInDays = expires === null ? null : Number(expires) if ( expires !== null && - !( - /^\d+$/.test(expires) && - Number(expires) >= CLI_AUTH_MIN_EXPIRY_DAYS && - Number(expires) <= CLI_AUTH_MAX_EXPIRY_DAYS - ) + (!/^\d+$/.test(expires) || + expiresInDays === null || + expiresInDays < CLI_AUTH_MIN_EXPIRY_DAYS || + expiresInDays > CLI_AUTH_MAX_EXPIRY_DAYS) ) { return { valid: false, diff --git a/apps/sim/lib/api/contracts/api-keys.ts b/apps/sim/lib/api/contracts/api-keys.ts index 04cbc0efc0f..8c7cbe78533 100644 --- a/apps/sim/lib/api/contracts/api-keys.ts +++ b/apps/sim/lib/api/contracts/api-keys.ts @@ -4,7 +4,7 @@ import { defineRouteContract } from '@/lib/api/contracts/types' /** One day in milliseconds: a key's expiry options and its creation bounds are counted in days. */ export const DAY_MS = 24 * 60 * 60 * 1000 -const apiKeyAccessSchema = z.enum(['read', 'write']) +export const apiKeyAccessSchema = z.enum(['read', 'write']) export type ApiKeyAccessLevel = z.output const apiKeyMetadataSchema = z.object({ diff --git a/apps/sim/lib/api/contracts/cli-auth.ts b/apps/sim/lib/api/contracts/cli-auth.ts index c4f0e9759a4..2e6e2832bce 100644 --- a/apps/sim/lib/api/contracts/cli-auth.ts +++ b/apps/sim/lib/api/contracts/cli-auth.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { apiKeyAccessSchema } from '@/lib/api/contracts/api-keys' import { workspaceIdSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' @@ -29,9 +30,6 @@ const base64Url43 = (message: string) => z.string().regex(/^[A-Za-z0-9\-_]{43}$/ export const cliAuthScopeSchema = z.enum(['copilot', 'platform']).default('copilot') export type CliAuthScope = z.output -/** Access a minted platform key carries: full (`write`) or read-only (`read`). */ -const cliAuthKeyAccessSchema = z.enum(['read', 'write']) - /** * Bounds on a minted platform key's lifetime, in whole days. The same window * every other key-creation surface allows: one day to one year. @@ -72,7 +70,7 @@ export const approveCliAuthBodySchema = z.object({ * approves; recording it here, rather than reading it at poll time, is what * keeps the unauthenticated poll from widening it. */ - access: cliAuthKeyAccessSchema.optional().default('write'), + access: apiKeyAccessSchema.optional().default('write'), /** Platform scope only: days until the minted key expires. Absent means it never expires. */ expiresInDays: cliAuthExpiresInDaysSchema.optional(), }) @@ -132,7 +130,7 @@ export const pollCliAuthContract = defineRouteContract({ * A terminal that asked for read-only must check this rather than * assume it — a server that predates the field mints full access. */ - access: cliAuthKeyAccessSchema.optional(), + access: apiKeyAccessSchema.optional(), /** Platform scope only: when the key expires, or null when it never does. */ expiresAt: z.string().nullable().optional(), }), diff --git a/apps/sim/lib/workspaces/application/update-workspace.ts b/apps/sim/lib/workspaces/application/update-workspace.ts index 82b501d4f9f..dae7dcaa7d0 100644 --- a/apps/sim/lib/workspaces/application/update-workspace.ts +++ b/apps/sim/lib/workspaces/application/update-workspace.ts @@ -12,7 +12,10 @@ import { resolveActiveWorkspaceApplicationContext, } from '@/lib/workspaces/application/context' import { workspaceUpdateOperations } from '@/lib/workspaces/application/operations' -import { countPublicWorkspaceMembers } from '@/lib/workspaces/public-queries' +import { + countPublicWorkspaceMembers, + type PublicWorkspaceDetail, +} from '@/lib/workspaces/public-queries' import { applyWorkspaceUpdate, readWorkspaceIdentityRecord, @@ -111,7 +114,15 @@ export const updateWorkspaceIdentity = defineAuthorizedWorkspaceUseCase({ ...result, patch, identity: presentIdentity(result.updated), - workspace: { id, name, color, logoUrl, memberCount, createdAt, updatedAt }, + workspace: { + id, + name, + color, + logoUrl, + memberCount, + createdAt, + updatedAt, + } satisfies PublicWorkspaceDetail, } }, projectAudit: ({ result }) => projectUpdateAudit(result.patch, result), From 3f070382464f943d00b97032cfe233bbcedaf3ed Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Wed, 11 Nov 2026 14:14:38 -0800 Subject: [PATCH 6/6] fix(api): publish the workspace PATCH body rules and check CLI-minted keys exactly - PATCH /v2/workspaces/{id}: the body schema publishes minProperties 1, and logoUrl is a pattern (root-relative path or https URL, protocol-relative refused) instead of an unpublished refine. - sim login refuses a minted key whose access or expiry differs from the request in either direction, with the expiry checked against the requested days within a clock tolerance. - sim workspaces update --logo-url documents that clearing the logo goes through the API or MCP. --- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/workspaces.mdx | 2 +- apps/docs/openapi-v2-resources.json | 6 ++- apps/sim/lib/api/contracts/v2/workspaces.ts | 9 +++-- packages/sim-cli/src/commands/auth.test.ts | 21 +++++++++- packages/sim-cli/src/commands/auth.ts | 43 +++++++++++++++------ packages/sim-cli/src/contract/commands.ts | 9 +++++ packages/sim-cli/src/generated/v2-api.ts | 6 ++- 8 files changed, 77 insertions(+), 21 deletions(-) diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 5e53873a271..8a190a7723a 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -9255,7 +9255,7 @@ sim workspaces update [options] | Option | Required | Description | | --- | --- | --- | | `--name ` | No | New workspace display name. | -| `--logo-url ` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). | +| `--logo-url ` | No | New workspace logo: a root-relative path (starting with /) or an https:// URL. To remove the logo, send logoUrl: null through the API or MCP (--logo-url null sends the word, not JSON null). | diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index 0729033516f..b3d5f9b4306 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -56,7 +56,7 @@ sim workspaces update [options] | Option | Required | Description | | --- | --- | --- | | `--name ` | No | New workspace display name. | -| `--logo-url ` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). | +| `--logo-url ` | No | New workspace logo: a root-relative path (starting with /) or an https:// URL. To remove the logo, send logoUrl: null through the API or MCP (--logo-url null sends the word, not JSON null). | diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index 2c6a6949932..7c41c46ae59 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -15258,10 +15258,11 @@ "maxLength": 255 }, "logoUrl": { - "description": "New workspace logo URL, or null to remove the logo.", + "description": "New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.", "anyOf": [ { - "type": "string" + "type": "string", + "pattern": "^(\\/(?!\\/)|https:\\/\\/)" }, { "type": "null" @@ -15270,6 +15271,7 @@ } }, "additionalProperties": false, + "minProperties": 1, "title": "Update workspace body", "description": "The workspace fields to change.", "examples": [ diff --git a/apps/sim/lib/api/contracts/v2/workspaces.ts b/apps/sim/lib/api/contracts/v2/workspaces.ts index 3c67dbcf487..c8965585fa5 100644 --- a/apps/sim/lib/api/contracts/v2/workspaces.ts +++ b/apps/sim/lib/api/contracts/v2/workspaces.ts @@ -121,17 +121,18 @@ const v2UpdateWorkspaceBodySchema = z .describe('New workspace display name.'), logoUrl: z .string() - .refine((value) => value.startsWith('/') || value.startsWith('https://'), { - message: 'logoUrl must be an absolute path or an HTTPS URL', - }) + .regex(/^(\/(?!\/)|https:\/\/)/, 'logoUrl must be a root-relative path or an HTTPS URL') .nullable() .optional() - .describe('New workspace logo URL, or null to remove the logo.'), + .describe( + 'New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.' + ), }) .strict() .refine((body) => body.name !== undefined || body.logoUrl !== undefined, { message: 'Provide name or logoUrl', }) + .meta({ minProperties: 1 }) export const v2UpdateWorkspaceContract = defineRouteContract({ method: 'PATCH', diff --git a/packages/sim-cli/src/commands/auth.test.ts b/packages/sim-cli/src/commands/auth.test.ts index 79d69f6fb6e..4b1661c5088 100644 --- a/packages/sim-cli/src/commands/auth.test.ts +++ b/packages/sim-cli/src/commands/auth.test.ts @@ -343,7 +343,9 @@ describe('login command', () => { expiresAt: null, }) - await expect(login('--read-only')).rejects.toThrow('not the read-only key requested') + await expect(login('--read-only')).rejects.toThrow( + 'issued a full-access key, not the key requested' + ) expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain( @@ -351,6 +353,23 @@ describe('login command', () => { ) }) + it('stores nothing when the issued expiry is not the one requested', async () => { + setInteractive(false) + mocks.pollForKey.mockResolvedValue({ + id: 'key-id', + apiKey: 'sim-key', + scope: 'platform', + workspaceBound: false, + workspaceId: 'ws_1', + access: 'write', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }) + + await expect(login('--expires-in', '30')).rejects.toThrow('not the key requested') + + expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled() + }) + it('stores nothing when the server answers with a malformed key', async () => { setInteractive(false) mocks.pollForKey.mockResolvedValue({ diff --git a/packages/sim-cli/src/commands/auth.ts b/packages/sim-cli/src/commands/auth.ts index 6633f90aed9..755022130ef 100644 --- a/packages/sim-cli/src/commands/auth.ts +++ b/packages/sim-cli/src/commands/auth.ts @@ -639,26 +639,47 @@ export function loginCommand(): Command { }) } +const DAY_MS = 24 * 60 * 60 * 1000 + +/** + * Clock difference tolerated between this machine and the server when checking + * a minted key's expiry against the lifetime requested. + */ +const EXPIRY_TOLERANCE_MS = 60 * 60 * 1000 + /** - * Refuses a minted key that is not the one requested. + * Refuses a minted key that is not exactly the one requested. * * The approval decides what is minted, and a server that predates read-only * and expiring handoff keys ignores the request and mints a full-access key - * that never expires. Storing that after `--read-only` would hand back the - * opposite of what was asked for. + * that never expires. A server that reports anything else — full access after + * `--read-only`, read-only without it, a lifetime other than `--expires-in` — + * minted a key nobody asked for, and storing it would hide that. */ function requireRequestedKey(key: MintedKey, keyRequest: KeyRequest): void { - if (keyRequest.readOnly && key.access !== 'read') { - throw new SimApiError( - 'The server issued a full-access key, not the read-only key requested. Nothing was stored; update the Sim deployment or drop --read-only.', + const refuse = (issued: string, flag: string) => + new SimApiError( + `The server issued ${issued}, not the key requested. Nothing was stored; update the Sim deployment or adjust ${flag}.`, 0 ) + + const requestedAccess = keyRequest.readOnly ? 'read' : 'write' + // A server that predates the field reports none and mints full access. + const issuedAccess = key.access ?? 'write' + if (issuedAccess !== requestedAccess) { + throw refuse(issuedAccess === 'read' ? 'a read-only key' : 'a full-access key', '--read-only') } - if (keyRequest.expiresInDays !== undefined && !key.expiresAt) { - throw new SimApiError( - 'The server issued a key that never expires, not one with the requested expiry. Nothing was stored; update the Sim deployment or drop --expires-in.', - 0 - ) + + if (keyRequest.expiresInDays === undefined) { + if (key.expiresAt) + throw refuse(`a key that expires ${safeOneLine(key.expiresAt ?? '')}`, '--expires-in') + return + } + const expiresAt = key.expiresAt ? Date.parse(key.expiresAt) : Number.NaN + if (Number.isNaN(expiresAt)) throw refuse('a key that never expires', '--expires-in') + const requested = Date.now() + keyRequest.expiresInDays * DAY_MS + if (Math.abs(expiresAt - requested) > EXPIRY_TOLERANCE_MS) { + throw refuse(`a key that expires ${safeOneLine(key.expiresAt ?? '')}`, '--expires-in') } } diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 0a2aa999430..4555951216c 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1538,6 +1538,15 @@ export const CLI_CONTRACT: CliContract = { updateWorkspace: { profileWorkspacePath: true, fields: WORKSPACE_FIELDS, + // Body strings are sent as typed, so this flag cannot send JSON null; the + // literal "null" fails the path-or-HTTPS rule with a 400 rather than + // storing anything. Clearing stays an API and MCP capability. + flags: { + logoUrl: { + describe: + 'New workspace logo: a root-relative path (starting with /) or an https:// URL. To remove the logo, send logoUrl: null through the API or MCP', + }, + }, }, listWorkspaceMembers: { command: 'workspaces members list', diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 971ffba68a9..38811c935b3 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -25290,7 +25290,11 @@ export const V2_OPERATIONS = { workspaceKeyUnsupported: true, body: { name: { kind: 'string', describe: 'New workspace display name.' }, - logoUrl: { kind: 'string', describe: 'New workspace logo URL, or null to remove the logo.' }, + logoUrl: { + kind: 'string', + describe: + 'New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.', + }, }, }, updateWorkspaceForkExclusions: {