Skip to content

Commit 0b4d341

Browse files
authored
feat(secrets): add optional descriptions to workspace secrets (#6796)
* feat(secrets): add optional descriptions to workspace secrets Workspace secrets already have a backing credential row with a description column, but nothing surfaced it. Teammates had no way to record what a secret is for. - Add a Description field to the secret detail page, matching the integrations credential page, gated on workspace-secret admin - Fold the value and description editors into one Save/Discard pair and one unsaved-changes guard; two guards cannot coexist, since each seeds its own same-URL history entry - Match descriptions in the secrets settings search - Expose description on GET/PUT /api/v2/secrets and in the CLI Descriptions are workspace-only: env_personal credential rows are per-workspace mirrors of one user-global secret, so one saved there would exist in a single workspace, and a personal secret has no teammates to inform. The API rejects a description on personal scope rather than silently dropping it, and omitting it on PUT leaves any existing description untouched so a value rotation cannot erase it. * fix(secrets): address review findings on secret descriptions - Patch the credential detail cache optimistically on update. `onMutate` cancelled the detail query but only patched the lists, so a detail-backed editor stayed dirty after a successful save until the refetch landed — long enough for Discard to restore the pre-save value over the committed one, and for Back to open the unsaved-changes guard. - Memoize `useSecretValue`'s returned callbacks and object, per the hook convention, so the composed form's save/discard stop churning per render. - Reject a description on a personal secret in the domain layer rather than only at the v2 boundary. The internal credential update path accepted one for any type, writing data every reader hides. - Normalize an empty description to null so the API and UI agree. - Correct the secrets documentation, which described a Display Name field the detail view does not have and omitted the scope rule. - Drop the CLI's copy of the 500-character bound; it can't import the contract, so a copy only drifts from the message the API already returns. - Collapse a redundant save guard and align the description write gate with the render gate. Leaves the integrations credential page byte-identical to staging. * fix(secrets): keep the API docs example and CLI column order stable Backward-compatibility fixes for anyone who never sets a description. - Move the blank-to-null normalization out of the contract and into the route. A Zod `.transform()` on any property drops the whole request schema's OpenAPI examples, which had silently removed the Set Secret request example from the published docs. - Append the CLI `description` column instead of inserting it before `updated`. `--output text` is positional, so inserting would shift every field an existing script cuts. - Reject a description on a personal secret with a message that says so, rather than dropping the field and falling through to the generic "no updatable fields" error.
1 parent 3abac09 commit 0b4d341

24 files changed

Lines changed: 574 additions & 63 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1787,6 +1787,7 @@ sim secrets set <name> [options]
17871787
| --- | --- | --- |
17881788
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
17891789
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
1790+
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
17901791

17911792
</CommandTable>
17921793

apps/docs/content/docs/en/cli/secrets.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,5 +80,6 @@ sim secrets set <name> [options]
8080
| --- | --- | --- |
8181
| `--scope <scope>` | Yes | Secret ownership scope. Accepted values: `workspace`, `personal`. |
8282
| `--value <value>` | No | Secret value; visible to shell history when supplied directly. |
83+
| `--description <description>` | No | What the secret is for, shown to teammates; workspace scope only. Omit to leave an existing description unchanged. |
8384

8485
</CommandTable>

apps/docs/content/docs/en/platform/credentials.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,15 @@ Click **Details** on any secret row to open its detail view.
9595

9696
<Image
9797
src="/static/secrets/secret-details.png"
98-
alt="Secret details view showing Display Name, Description, and Members sections"
98+
alt="Secret details view showing Key, Value, Description, and Members sections"
9999
width={700}
100100
height={400}
101101
/>
102102

103103
From here you can:
104104

105-
- Edit the **Display Name** and **Description**
105+
- View the **Key** and edit the **Value**
106+
- Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none
106107
- Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role
107108

108109
Click **Save** to apply changes, or **Back** to return to the list.

apps/docs/openapi-v2-resources.json

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5052,6 +5052,17 @@
50525052
"enum": ["workspace", "personal"],
50535053
"description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace."
50545054
},
5055+
"description": {
5056+
"anyOf": [
5057+
{
5058+
"type": "string"
5059+
},
5060+
{
5061+
"type": "null"
5062+
}
5063+
],
5064+
"description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience."
5065+
},
50555066
"role": {
50565067
"type": "string",
50575068
"enum": ["admin", "member"],
@@ -5070,7 +5081,7 @@
50705081
"description": "ISO 8601 timestamp when the secret was last updated."
50715082
}
50725083
},
5073-
"required": ["name", "scope", "role", "createdAt", "updatedAt"],
5084+
"required": ["name", "scope", "description", "role", "createdAt", "updatedAt"],
50745085
"additionalProperties": false,
50755086
"title": "Secret metadata",
50765087
"description": "Public secret metadata without the stored secret value."
@@ -5107,6 +5118,7 @@
51075118
{
51085119
"name": "STRIPE_API_KEY",
51095120
"scope": "workspace",
5121+
"description": "Production billing key — rotate quarterly.",
51105122
"role": "admin",
51115123
"createdAt": "2026-06-01T09:14:00.000Z",
51125124
"updatedAt": "2026-06-20T14:02:11.000Z"
@@ -5133,6 +5145,7 @@
51335145
"data": {
51345146
"name": "STRIPE_API_KEY",
51355147
"scope": "workspace",
5148+
"description": "Production billing key — rotate quarterly.",
51365149
"role": "admin",
51375150
"createdAt": "2026-06-01T09:14:00.000Z",
51385151
"updatedAt": "2026-06-20T14:02:11.000Z"
@@ -5160,6 +5173,18 @@
51605173
"maxLength": 65536,
51615174
"description": "Write-only secret value. It is never returned.",
51625175
"writeOnly": true
5176+
},
5177+
"description": {
5178+
"description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.",
5179+
"anyOf": [
5180+
{
5181+
"type": "string",
5182+
"maxLength": 500
5183+
},
5184+
{
5185+
"type": "null"
5186+
}
5187+
]
51635188
}
51645189
},
51655190
"required": ["workspaceId", "scope", "value"],

apps/sim/app/api/v2/secrets/[name]/route.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,69 @@ describe('/api/v2/secrets/[name]', () => {
135135
})
136136
})
137137

138+
it('forwards a workspace description to the set operation', async () => {
139+
const response = await PUT(
140+
request('PUT', {
141+
workspaceId: WORKSPACE_ID,
142+
scope: 'workspace',
143+
value: 'secret-value',
144+
description: ' Prod billing key ',
145+
}),
146+
context
147+
)
148+
149+
expect(response.status).toBe(201)
150+
expect(mocks.set).toHaveBeenCalledWith({
151+
principal: PRINCIPAL,
152+
input: {
153+
workspaceId: WORKSPACE_ID,
154+
name: SECRET_NAME,
155+
scope: 'workspace',
156+
value: 'secret-value',
157+
description: 'Prod billing key',
158+
},
159+
request: expect.anything(),
160+
})
161+
})
162+
163+
it('omits description entirely when unset so a rotation cannot erase it', async () => {
164+
await PUT(
165+
request('PUT', { workspaceId: WORKSPACE_ID, scope: 'workspace', value: 'rotated' }),
166+
context
167+
)
168+
169+
expect(mocks.set.mock.calls[0][0].input).not.toHaveProperty('description')
170+
})
171+
172+
it('normalizes an empty description to null so it matches the UI clear path', async () => {
173+
await PUT(
174+
request('PUT', {
175+
workspaceId: WORKSPACE_ID,
176+
scope: 'workspace',
177+
value: 'secret-value',
178+
description: ' ',
179+
}),
180+
context
181+
)
182+
183+
expect(mocks.set.mock.calls[0][0].input.description).toBeNull()
184+
})
185+
186+
it('rejects a description on a personal secret rather than dropping it', async () => {
187+
const response = await PUT(
188+
request('PUT', {
189+
workspaceId: WORKSPACE_ID,
190+
scope: 'personal',
191+
value: 'secret-value',
192+
description: 'has no shared audience',
193+
}),
194+
context
195+
)
196+
197+
expect(response.status).toBe(400)
198+
expect(mocks.set).not.toHaveBeenCalled()
199+
})
200+
138201
it('returns 200 when replacing an existing secret', async () => {
139202
mocks.set.mockResolvedValueOnce({ secret, userId: 'user-1', created: false })
140203

apps/sim/app/api/v2/secrets/[name]/route.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,16 @@ export const PUT = defineV2JsonRoute({
1919
auth: v2ApiKeyAuth,
2020
rateLimit: v2RateLimits.publicApi,
2121
errorPolicy: v2OrchestrationErrorPolicy,
22-
mapInput: ({ params, body }) => ({ ...body, name: params.name }),
22+
/**
23+
* Normalizes a blank description to an explicit clear here rather than in the
24+
* contract: a Zod `.transform()` on any property drops the whole request
25+
* schema's OpenAPI examples, silently removing them from the published docs.
26+
*/
27+
mapInput: ({ params, body }) => ({
28+
...body,
29+
name: params.name,
30+
...(body.description === '' ? { description: null } : {}),
31+
}),
2332
useCase: setSecretUseCase,
2433
statusForResult: ({ created }) => (created ? 201 : 200),
2534
present: ({ secret, userId }) => ({ data: toV2Secret(secret, userId) }),

apps/sim/app/api/v2/secrets/route.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ describe('GET /api/v2/secrets', () => {
113113
{
114114
name: 'STRIPE_API_KEY',
115115
scope: 'workspace',
116+
description: null,
116117
role: 'admin',
117118
createdAt: '2026-01-01T00:00:00.000Z',
118119
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -142,6 +143,38 @@ describe('GET /api/v2/secrets', () => {
142143
* `mapInput` — because the contract-level sweep only checks a hand-maintained
143144
* map of param names and stays green when a route drops the stamp entirely.
144145
*/
146+
it('reports a workspace secret description and never a personal one', async () => {
147+
mocks.list.mockResolvedValue({
148+
secrets: [
149+
{ ...secret, description: 'Prod billing key' },
150+
{
151+
...secret,
152+
id: 'secret-2',
153+
type: 'env_personal' as const,
154+
displayName: 'MY_TEST_KEY',
155+
envKey: 'MY_TEST_KEY',
156+
envOwnerUserId: 'user-1',
157+
description: 'leaked from a workspace mirror',
158+
},
159+
],
160+
userId: 'user-1',
161+
nextCursorKeys: null,
162+
sortBy: 'name',
163+
sortOrder: 'asc',
164+
})
165+
166+
const response = await GET(
167+
new NextRequest(`http://localhost:3000/api/v2/secrets?workspaceId=${WORKSPACE_ID}`, {
168+
headers: { 'x-api-key': 'key' },
169+
})
170+
)
171+
const body = await response.json()
172+
173+
expect(response.status).toBe(200)
174+
expect(body.data[0].description).toBe('Prod billing key')
175+
expect(body.data[1].description).toBeNull()
176+
})
177+
145178
it('refuses a cursor minted under a different filter', async () => {
146179
mocks.list.mockResolvedValue({
147180
secrets: [secret],

apps/sim/app/api/v2/secrets/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export function toV2Secret(row: VisibleWorkspaceCredential, userId: string): V2S
1313
return {
1414
name: row.envKey,
1515
scope: row.type === 'env_workspace' ? 'workspace' : 'personal',
16+
description: row.type === 'env_workspace' ? row.description : null,
1617
role: row.role,
1718
createdAt: row.createdAt.toISOString(),
1819
updatedAt: row.updatedAt.toISOString(),

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,48 @@ import { useUnsavedChangesGuard } from './use-unsaved-changes-guard'
99

1010
const logger = createLogger('CredentialDetailForm')
1111

12+
/**
13+
* A second editable section rendered on the same detail page (e.g. a secret's
14+
* value), whose lifecycle is folded into the form's.
15+
*/
16+
export interface CredentialDetailFormSection {
17+
isDirty: boolean
18+
isSaving: boolean
19+
/**
20+
* Resolves true when the caller may proceed — including when there was nothing
21+
* to write. False only when a write was attempted and failed, which stops the
22+
* metadata save from committing alone.
23+
*/
24+
save: () => Promise<boolean>
25+
discard: () => void
26+
}
27+
1228
interface UseCredentialDetailFormParams {
1329
credential: WorkspaceCredential | null
1430
isAdmin: boolean
1531
/** Where the back link / discard navigates to. */
1632
backHref: string
33+
/**
34+
* An additional editable section on the page, folded into one dirty state, one
35+
* save, and one unsaved-changes guard. Two independent guards on a page cannot
36+
* coexist: each seeds its own same-URL history entry while dirty, so Back would
37+
* pop only one of them and leave the other stranded.
38+
*/
39+
section?: CredentialDetailFormSection
1740
}
1841

1942
/**
2043
* Shared editable-metadata controller for a credential detail page: Display Name
2144
* and Description drafts seeded from the credential, dirty tracking, an
22-
* admin-only save, and the shared unsaved-changes guard.
45+
* admin-only save, and the shared unsaved-changes guard. An optional
46+
* {@link CredentialDetailFormSection} folds a second editor on the same page
47+
* into that one save and one guard.
2348
*/
2449
export function useCredentialDetailForm({
2550
credential,
2651
isAdmin,
2752
backHref,
53+
section,
2854
}: UseCredentialDetailFormParams) {
2955
const updateCredential = useUpdateWorkspaceCredential()
3056

@@ -50,12 +76,18 @@ export function useCredentialDetailForm({
5076
const isDescriptionDirty = credential
5177
? descriptionDraft !== (credential.description || '')
5278
: false
53-
const isDirty = isDisplayNameDirty || isDescriptionDirty
79+
const isMetadataDirty = isDisplayNameDirty || isDescriptionDirty
80+
const isSectionDirty = section?.isDirty ?? false
81+
const isDirty = isMetadataDirty || isSectionDirty
82+
const isSaving = updateCredential.isPending || (section?.isSaving ?? false)
5483

5584
const guard = useUnsavedChangesGuard({ isDirty, backHref })
5685

5786
const save = useCallback(async () => {
58-
if (!credential || !isAdmin || !isDirty || updateCredential.isPending) return
87+
if (!credential || isSaving) return
88+
if (isSectionDirty && !(await section?.save())) return
89+
if (!isAdmin || !isMetadataDirty) return
90+
5991
try {
6092
await updateCredential.mutateAsync({
6193
credentialId: credential.id,
@@ -73,18 +105,21 @@ export function useCredentialDetailForm({
73105
}, [
74106
credential,
75107
isAdmin,
76-
isDirty,
108+
isMetadataDirty,
109+
isSectionDirty,
110+
isSaving,
111+
section,
77112
isDisplayNameDirty,
78113
isDescriptionDirty,
79114
displayNameDraft,
80115
descriptionDraft,
81116
updateCredential.mutateAsync,
82-
updateCredential.isPending,
83117
])
84118

85119
const discard = useCallback(() => {
86120
if (credential) seedDrafts(credential)
87-
}, [credential, seedDrafts])
121+
section?.discard()
122+
}, [credential, section, seedDrafts])
88123

89124
return {
90125
displayNameDraft,
@@ -94,7 +129,7 @@ export function useCredentialDetailForm({
94129
isDirty,
95130
save,
96131
discard,
97-
isSaving: updateCredential.isPending,
132+
isSaving,
98133
handleBackClick: guard.handleBackClick,
99134
showUnsavedAlert: guard.showUnsavedAlert,
100135
setShowUnsavedAlert: guard.setShowUnsavedAlert,

apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,21 @@ export function SecretsManager() {
419419
return mapped.filter(({ envVar }) => envVar.key.toLowerCase().includes(term))
420420
}, [envVars, searchTerm])
421421

422+
/**
423+
* The row has no description column, so a description-only match is legible
424+
* only on the secret's detail page. Personal secrets carry no shared
425+
* description and stay key-only.
426+
*/
422427
const filteredWorkspaceEntries = useMemo(() => {
423428
const entries = Object.entries(workspaceVars)
424429
if (!searchTerm.trim()) return entries
425430
const term = searchTerm.toLowerCase()
426-
return entries.filter(([key]) => key.toLowerCase().includes(term))
427-
}, [workspaceVars, searchTerm])
431+
return entries.filter(
432+
([key]) =>
433+
key.toLowerCase().includes(term) ||
434+
Boolean(workspaceEnvKeyToCredential.get(key)?.description?.toLowerCase().includes(term))
435+
)
436+
}, [workspaceVars, searchTerm, workspaceEnvKeyToCredential])
428437

429438
const filteredNewWorkspaceRows = useMemo(() => {
430439
const mapped = newWorkspaceRows.map((row, index) => ({ row, originalIndex: index }))

0 commit comments

Comments
 (0)