Skip to content

Commit 3f07038

Browse files
committed
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.
1 parent e72693b commit 3f07038

8 files changed

Lines changed: 77 additions & 21 deletions

File tree

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9255,7 +9255,7 @@ sim workspaces update [options]
92559255
| Option | Required | Description |
92569256
| --- | --- | --- |
92579257
| `--name <value>` | No | New workspace display name. |
9258-
| `--logo-url <value>` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). |
9258+
| `--logo-url <value>` | 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). |
92599259

92609260
</CommandTable>
92619261

‎apps/docs/content/docs/cli/workspaces.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ sim workspaces update [options]
5656
| Option | Required | Description |
5757
| --- | --- | --- |
5858
| `--name <value>` | No | New workspace display name. |
59-
| `--logo-url <value>` | No | New workspace logo URL, or null to remove the logo. (--logo-url null sends the word, not JSON null). |
59+
| `--logo-url <value>` | 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). |
6060

6161
</CommandTable>
6262

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15258,10 +15258,11 @@
1525815258
"maxLength": 255
1525915259
},
1526015260
"logoUrl": {
15261-
"description": "New workspace logo URL, or null to remove the logo.",
15261+
"description": "New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.",
1526215262
"anyOf": [
1526315263
{
15264-
"type": "string"
15264+
"type": "string",
15265+
"pattern": "^(\\/(?!\\/)|https:\\/\\/)"
1526515266
},
1526615267
{
1526715268
"type": "null"
@@ -15270,6 +15271,7 @@
1527015271
}
1527115272
},
1527215273
"additionalProperties": false,
15274+
"minProperties": 1,
1527315275
"title": "Update workspace body",
1527415276
"description": "The workspace fields to change.",
1527515277
"examples": [

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,18 @@ const v2UpdateWorkspaceBodySchema = z
121121
.describe('New workspace display name.'),
122122
logoUrl: z
123123
.string()
124-
.refine((value) => value.startsWith('/') || value.startsWith('https://'), {
125-
message: 'logoUrl must be an absolute path or an HTTPS URL',
126-
})
124+
.regex(/^(\/(?!\/)|https:\/\/)/, 'logoUrl must be a root-relative path or an HTTPS URL')
127125
.nullable()
128126
.optional()
129-
.describe('New workspace logo URL, or null to remove the logo.'),
127+
.describe(
128+
'New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.'
129+
),
130130
})
131131
.strict()
132132
.refine((body) => body.name !== undefined || body.logoUrl !== undefined, {
133133
message: 'Provide name or logoUrl',
134134
})
135+
.meta({ minProperties: 1 })
135136

136137
export const v2UpdateWorkspaceContract = defineRouteContract({
137138
method: 'PATCH',

‎packages/sim-cli/src/commands/auth.test.ts‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,14 +343,33 @@ describe('login command', () => {
343343
expiresAt: null,
344344
})
345345

346-
await expect(login('--read-only')).rejects.toThrow('not the read-only key requested')
346+
await expect(login('--read-only')).rejects.toThrow(
347+
'issued a full-access key, not the key requested'
348+
)
347349

348350
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
349351
expect(vi.mocked(console.log).mock.calls.flat().join('\n')).toContain(
350352
'API key key-id was created but could not be stored safely'
351353
)
352354
})
353355

356+
it('stores nothing when the issued expiry is not the one requested', async () => {
357+
setInteractive(false)
358+
mocks.pollForKey.mockResolvedValue({
359+
id: 'key-id',
360+
apiKey: 'sim-key',
361+
scope: 'platform',
362+
workspaceBound: false,
363+
workspaceId: 'ws_1',
364+
access: 'write',
365+
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
366+
})
367+
368+
await expect(login('--expires-in', '30')).rejects.toThrow('not the key requested')
369+
370+
expect(mocks.writeCredentialsProfile).not.toHaveBeenCalled()
371+
})
372+
354373
it('stores nothing when the server answers with a malformed key', async () => {
355374
setInteractive(false)
356375
mocks.pollForKey.mockResolvedValue({

‎packages/sim-cli/src/commands/auth.ts‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -639,26 +639,47 @@ export function loginCommand(): Command {
639639
})
640640
}
641641

642+
const DAY_MS = 24 * 60 * 60 * 1000
643+
644+
/**
645+
* Clock difference tolerated between this machine and the server when checking
646+
* a minted key's expiry against the lifetime requested.
647+
*/
648+
const EXPIRY_TOLERANCE_MS = 60 * 60 * 1000
649+
642650
/**
643-
* Refuses a minted key that is not the one requested.
651+
* Refuses a minted key that is not exactly the one requested.
644652
*
645653
* The approval decides what is minted, and a server that predates read-only
646654
* and expiring handoff keys ignores the request and mints a full-access key
647-
* that never expires. Storing that after `--read-only` would hand back the
648-
* opposite of what was asked for.
655+
* that never expires. A server that reports anything else — full access after
656+
* `--read-only`, read-only without it, a lifetime other than `--expires-in` —
657+
* minted a key nobody asked for, and storing it would hide that.
649658
*/
650659
function requireRequestedKey(key: MintedKey, keyRequest: KeyRequest): void {
651-
if (keyRequest.readOnly && key.access !== 'read') {
652-
throw new SimApiError(
653-
'The server issued a full-access key, not the read-only key requested. Nothing was stored; update the Sim deployment or drop --read-only.',
660+
const refuse = (issued: string, flag: string) =>
661+
new SimApiError(
662+
`The server issued ${issued}, not the key requested. Nothing was stored; update the Sim deployment or adjust ${flag}.`,
654663
0
655664
)
665+
666+
const requestedAccess = keyRequest.readOnly ? 'read' : 'write'
667+
// A server that predates the field reports none and mints full access.
668+
const issuedAccess = key.access ?? 'write'
669+
if (issuedAccess !== requestedAccess) {
670+
throw refuse(issuedAccess === 'read' ? 'a read-only key' : 'a full-access key', '--read-only')
656671
}
657-
if (keyRequest.expiresInDays !== undefined && !key.expiresAt) {
658-
throw new SimApiError(
659-
'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.',
660-
0
661-
)
672+
673+
if (keyRequest.expiresInDays === undefined) {
674+
if (key.expiresAt)
675+
throw refuse(`a key that expires ${safeOneLine(key.expiresAt ?? '')}`, '--expires-in')
676+
return
677+
}
678+
const expiresAt = key.expiresAt ? Date.parse(key.expiresAt) : Number.NaN
679+
if (Number.isNaN(expiresAt)) throw refuse('a key that never expires', '--expires-in')
680+
const requested = Date.now() + keyRequest.expiresInDays * DAY_MS
681+
if (Math.abs(expiresAt - requested) > EXPIRY_TOLERANCE_MS) {
682+
throw refuse(`a key that expires ${safeOneLine(key.expiresAt ?? '')}`, '--expires-in')
662683
}
663684
}
664685

‎packages/sim-cli/src/contract/commands.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1538,6 +1538,15 @@ export const CLI_CONTRACT: CliContract = {
15381538
updateWorkspace: {
15391539
profileWorkspacePath: true,
15401540
fields: WORKSPACE_FIELDS,
1541+
// Body strings are sent as typed, so this flag cannot send JSON null; the
1542+
// literal "null" fails the path-or-HTTPS rule with a 400 rather than
1543+
// storing anything. Clearing stays an API and MCP capability.
1544+
flags: {
1545+
logoUrl: {
1546+
describe:
1547+
'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',
1548+
},
1549+
},
15411550
},
15421551
listWorkspaceMembers: {
15431552
command: 'workspaces members list',

‎packages/sim-cli/src/generated/v2-api.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25290,7 +25290,11 @@ export const V2_OPERATIONS = {
2529025290
workspaceKeyUnsupported: true,
2529125291
body: {
2529225292
name: { kind: 'string', describe: 'New workspace display name.' },
25293-
logoUrl: { kind: 'string', describe: 'New workspace logo URL, or null to remove the logo.' },
25293+
logoUrl: {
25294+
kind: 'string',
25295+
describe:
25296+
'New workspace logo URL: a root-relative path (starting with /) or an https:// URL. Send null to remove the logo.',
25297+
},
2529425298
},
2529525299
},
2529625300
updateWorkspaceForkExclusions: {

0 commit comments

Comments
 (0)