@agent-native/dispatch 0.8.2 → 0.8.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/update-vault-secret.d.ts.map +1 -1
- package/dist/actions/update-vault-secret.js +34 -6
- package/dist/actions/update-vault-secret.js.map +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/extensions.$id.d.ts +3 -0
- package/dist/routes/pages/extensions.$id.d.ts.map +1 -1
- package/dist/routes/pages/extensions.$id.js +5 -2
- package/dist/routes/pages/extensions.$id.js.map +1 -1
- package/dist/routes/pages/extensions._index.d.ts +3 -0
- package/dist/routes/pages/extensions._index.d.ts.map +1 -1
- package/dist/routes/pages/extensions._index.js +5 -2
- package/dist/routes/pages/extensions._index.js.map +1 -1
- package/dist/routes/pages/tools.$id.d.ts +3 -0
- package/dist/routes/pages/tools.$id.d.ts.map +1 -1
- package/dist/routes/pages/tools.$id.js +3 -0
- package/dist/routes/pages/tools.$id.js.map +1 -1
- package/dist/routes/pages/tools._index.d.ts +3 -0
- package/dist/routes/pages/tools._index.d.ts.map +1 -1
- package/dist/routes/pages/tools._index.js +3 -0
- package/dist/routes/pages/tools._index.js.map +1 -1
- package/dist/routes/pages/vault.d.ts.map +1 -1
- package/dist/routes/pages/vault.js +46 -2
- package/dist/routes/pages/vault.js.map +1 -1
- package/dist/server/lib/vault-store.d.ts +8 -1
- package/dist/server/lib/vault-store.d.ts.map +1 -1
- package/dist/server/lib/vault-store.js +90 -5
- package/dist/server/lib/vault-store.js.map +1 -1
- package/package.json +1 -1
- package/src/actions/update-vault-secret.ts +39 -6
- package/src/routes/index.ts +1 -0
- package/src/routes/pages/extensions.$id.tsx +6 -2
- package/src/routes/pages/extensions._index.tsx +6 -2
- package/src/routes/pages/tools.$id.tsx +4 -0
- package/src/routes/pages/tools._index.tsx +4 -0
- package/src/routes/pages/vault.tsx +172 -1
- package/src/server/lib/vault-store.spec.ts +80 -0
- package/src/server/lib/vault-store.ts +125 -5
|
@@ -4,6 +4,7 @@ import { toast } from "sonner";
|
|
|
4
4
|
import {
|
|
5
5
|
IconChevronDown,
|
|
6
6
|
IconChevronRight,
|
|
7
|
+
IconEdit,
|
|
7
8
|
IconEye,
|
|
8
9
|
IconEyeOff,
|
|
9
10
|
IconKey,
|
|
@@ -61,6 +62,7 @@ const PROVIDERS = [
|
|
|
61
62
|
"anthropic",
|
|
62
63
|
"other",
|
|
63
64
|
];
|
|
65
|
+
const PROVIDER_NONE_VALUE = "__none__";
|
|
64
66
|
|
|
65
67
|
type VaultAccessMode = "all-apps" | "manual";
|
|
66
68
|
|
|
@@ -177,6 +179,174 @@ function AddSecretDialog() {
|
|
|
177
179
|
);
|
|
178
180
|
}
|
|
179
181
|
|
|
182
|
+
function EditSecretDialog({ secret }: { secret: any }) {
|
|
183
|
+
const [open, setOpen] = useState(false);
|
|
184
|
+
const [credentialKey, setCredentialKey] = useState(
|
|
185
|
+
secret.credentialKey || "",
|
|
186
|
+
);
|
|
187
|
+
const [name, setName] = useState(secret.name || "");
|
|
188
|
+
const [value, setValue] = useState(secret.value || "");
|
|
189
|
+
const [provider, setProvider] = useState(secret.provider || "");
|
|
190
|
+
const [description, setDescription] = useState(secret.description || "");
|
|
191
|
+
const [showValue, setShowValue] = useState(false);
|
|
192
|
+
|
|
193
|
+
const update = useActionMutation("update-vault-secret", {
|
|
194
|
+
onSuccess: () => {
|
|
195
|
+
toast.success("Secret updated");
|
|
196
|
+
setOpen(false);
|
|
197
|
+
setShowValue(false);
|
|
198
|
+
},
|
|
199
|
+
onError: (err) => toast.error(String(err)),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const resetDraft = () => {
|
|
203
|
+
setCredentialKey(secret.credentialKey || "");
|
|
204
|
+
setName(secret.name || "");
|
|
205
|
+
setValue(secret.value || "");
|
|
206
|
+
setProvider(secret.provider || "");
|
|
207
|
+
setDescription(secret.description || "");
|
|
208
|
+
setShowValue(false);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
<Dialog
|
|
213
|
+
open={open}
|
|
214
|
+
onOpenChange={(nextOpen) => {
|
|
215
|
+
if (nextOpen) resetDraft();
|
|
216
|
+
setOpen(nextOpen);
|
|
217
|
+
}}
|
|
218
|
+
>
|
|
219
|
+
<DialogTrigger asChild>
|
|
220
|
+
<Button variant="outline" size="sm">
|
|
221
|
+
<IconEdit size={14} className="mr-1" />
|
|
222
|
+
Edit secret
|
|
223
|
+
</Button>
|
|
224
|
+
</DialogTrigger>
|
|
225
|
+
<DialogContent>
|
|
226
|
+
<DialogHeader>
|
|
227
|
+
<DialogTitle>Edit vault secret</DialogTitle>
|
|
228
|
+
<DialogDescription>
|
|
229
|
+
Update the stored key and metadata. Changes sync to the shared
|
|
230
|
+
credential store.
|
|
231
|
+
</DialogDescription>
|
|
232
|
+
</DialogHeader>
|
|
233
|
+
<form
|
|
234
|
+
className="space-y-4 py-2"
|
|
235
|
+
onSubmit={(event) => {
|
|
236
|
+
event.preventDefault();
|
|
237
|
+
update.mutate({
|
|
238
|
+
id: secret.id,
|
|
239
|
+
credentialKey,
|
|
240
|
+
name,
|
|
241
|
+
value,
|
|
242
|
+
provider: provider || null,
|
|
243
|
+
description: description || null,
|
|
244
|
+
});
|
|
245
|
+
}}
|
|
246
|
+
>
|
|
247
|
+
<div className="space-y-2">
|
|
248
|
+
<Label htmlFor={`vault-secret-name-${secret.id}`}>Name</Label>
|
|
249
|
+
<Input
|
|
250
|
+
id={`vault-secret-name-${secret.id}`}
|
|
251
|
+
value={name}
|
|
252
|
+
onChange={(e) => setName(e.target.value)}
|
|
253
|
+
/>
|
|
254
|
+
</div>
|
|
255
|
+
<div className="space-y-2">
|
|
256
|
+
<Label htmlFor={`vault-secret-key-${secret.id}`}>
|
|
257
|
+
Credential key (env var name)
|
|
258
|
+
</Label>
|
|
259
|
+
<Input
|
|
260
|
+
id={`vault-secret-key-${secret.id}`}
|
|
261
|
+
value={credentialKey}
|
|
262
|
+
onChange={(e) => setCredentialKey(e.target.value)}
|
|
263
|
+
className="font-mono text-sm"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="space-y-2">
|
|
267
|
+
<Label htmlFor={`vault-secret-value-${secret.id}`}>Value</Label>
|
|
268
|
+
<div className="flex gap-2">
|
|
269
|
+
<Input
|
|
270
|
+
id={`vault-secret-value-${secret.id}`}
|
|
271
|
+
type={showValue ? "text" : "password"}
|
|
272
|
+
value={value}
|
|
273
|
+
onChange={(e) => setValue(e.target.value)}
|
|
274
|
+
className="font-mono text-sm"
|
|
275
|
+
/>
|
|
276
|
+
<Button
|
|
277
|
+
type="button"
|
|
278
|
+
variant="outline"
|
|
279
|
+
size="icon"
|
|
280
|
+
onClick={() => setShowValue((current) => !current)}
|
|
281
|
+
aria-label={
|
|
282
|
+
showValue ? "Hide secret value" : "Show secret value"
|
|
283
|
+
}
|
|
284
|
+
>
|
|
285
|
+
{showValue ? <IconEyeOff size={15} /> : <IconEye size={15} />}
|
|
286
|
+
</Button>
|
|
287
|
+
</div>
|
|
288
|
+
</div>
|
|
289
|
+
<div className="space-y-2">
|
|
290
|
+
<Label>Provider</Label>
|
|
291
|
+
<Select
|
|
292
|
+
value={provider || PROVIDER_NONE_VALUE}
|
|
293
|
+
onValueChange={(nextProvider) =>
|
|
294
|
+
setProvider(
|
|
295
|
+
nextProvider === PROVIDER_NONE_VALUE ? "" : nextProvider,
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
>
|
|
299
|
+
<SelectTrigger>
|
|
300
|
+
<SelectValue placeholder="Select a provider..." />
|
|
301
|
+
</SelectTrigger>
|
|
302
|
+
<SelectContent>
|
|
303
|
+
<SelectItem value={PROVIDER_NONE_VALUE}>No provider</SelectItem>
|
|
304
|
+
{PROVIDERS.map((p) => (
|
|
305
|
+
<SelectItem key={p} value={p}>
|
|
306
|
+
{p.charAt(0).toUpperCase() + p.slice(1)}
|
|
307
|
+
</SelectItem>
|
|
308
|
+
))}
|
|
309
|
+
</SelectContent>
|
|
310
|
+
</Select>
|
|
311
|
+
</div>
|
|
312
|
+
<div className="space-y-2">
|
|
313
|
+
<Label htmlFor={`vault-secret-description-${secret.id}`}>
|
|
314
|
+
Description
|
|
315
|
+
</Label>
|
|
316
|
+
<Textarea
|
|
317
|
+
id={`vault-secret-description-${secret.id}`}
|
|
318
|
+
placeholder="What is this secret used for?"
|
|
319
|
+
value={description}
|
|
320
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
321
|
+
rows={2}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
<DialogFooter>
|
|
325
|
+
<Button
|
|
326
|
+
type="button"
|
|
327
|
+
variant="outline"
|
|
328
|
+
onClick={() => setOpen(false)}
|
|
329
|
+
>
|
|
330
|
+
Cancel
|
|
331
|
+
</Button>
|
|
332
|
+
<Button
|
|
333
|
+
type="submit"
|
|
334
|
+
disabled={
|
|
335
|
+
!credentialKey.trim() ||
|
|
336
|
+
!name.trim() ||
|
|
337
|
+
!value ||
|
|
338
|
+
update.isPending
|
|
339
|
+
}
|
|
340
|
+
>
|
|
341
|
+
{update.isPending ? "Saving..." : "Save changes"}
|
|
342
|
+
</Button>
|
|
343
|
+
</DialogFooter>
|
|
344
|
+
</form>
|
|
345
|
+
</DialogContent>
|
|
346
|
+
</Dialog>
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
180
350
|
function GrantDialog({
|
|
181
351
|
secretId,
|
|
182
352
|
secretName,
|
|
@@ -429,7 +599,8 @@ function SecretRow({
|
|
|
429
599
|
)}
|
|
430
600
|
</div>
|
|
431
601
|
|
|
432
|
-
<div className="flex justify-end border-t pt-3">
|
|
602
|
+
<div className="flex justify-end gap-2 border-t pt-3">
|
|
603
|
+
<EditSecretDialog secret={secret} />
|
|
433
604
|
<AlertDialog>
|
|
434
605
|
<AlertDialogTrigger asChild>
|
|
435
606
|
<Button
|
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
|
+
deleteAppSecret: vi.fn(),
|
|
5
|
+
getDb: vi.fn(),
|
|
6
|
+
listAppSecretsForScope: vi.fn(),
|
|
4
7
|
writeAppSecret: vi.fn(),
|
|
5
8
|
}));
|
|
6
9
|
|
|
7
10
|
vi.mock("@agent-native/core/secrets", () => ({
|
|
11
|
+
deleteAppSecret: mocks.deleteAppSecret,
|
|
12
|
+
listAppSecretsForScope: mocks.listAppSecretsForScope,
|
|
8
13
|
writeAppSecret: mocks.writeAppSecret,
|
|
9
14
|
}));
|
|
10
15
|
|
|
16
|
+
vi.mock("../../db/index.js", async (importOriginal) => {
|
|
17
|
+
const actual = await importOriginal<typeof import("../../db/index.js")>();
|
|
18
|
+
return {
|
|
19
|
+
...actual,
|
|
20
|
+
getDb: mocks.getDb,
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
11
24
|
import {
|
|
25
|
+
cleanupSyncedCredentialKeysIfUnused,
|
|
12
26
|
credentialStoreScopeForVaultCtx,
|
|
13
27
|
syncSecretsToCredentialStore,
|
|
14
28
|
} from "./vault-store.js";
|
|
@@ -67,3 +81,69 @@ describe("syncSecretsToCredentialStore", () => {
|
|
|
67
81
|
});
|
|
68
82
|
});
|
|
69
83
|
});
|
|
84
|
+
|
|
85
|
+
describe("cleanupSyncedCredentialKeysIfUnused", () => {
|
|
86
|
+
function mockVaultSecretLookup(rows: Array<{ id: string }> = []) {
|
|
87
|
+
const query = {
|
|
88
|
+
select: vi.fn(() => query),
|
|
89
|
+
from: vi.fn(() => query),
|
|
90
|
+
where: vi.fn(() => query),
|
|
91
|
+
limit: vi.fn(async () => rows),
|
|
92
|
+
};
|
|
93
|
+
mocks.getDb.mockReturnValue(query);
|
|
94
|
+
return query;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
it("deletes a candidate synced credential when no vault secret still uses it", async () => {
|
|
98
|
+
mockVaultSecretLookup([]);
|
|
99
|
+
|
|
100
|
+
await cleanupSyncedCredentialKeysIfUnused(
|
|
101
|
+
{ ownerEmail: "admin@example.test", orgId: "org_123" },
|
|
102
|
+
["OLD_API_KEY"],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
expect(mocks.deleteAppSecret).toHaveBeenCalledWith({
|
|
106
|
+
key: "OLD_API_KEY",
|
|
107
|
+
scope: "org",
|
|
108
|
+
scopeId: "org_123",
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("keeps a candidate synced credential when another vault secret still uses it", async () => {
|
|
113
|
+
mockVaultSecretLookup([{ id: "secret_1" }]);
|
|
114
|
+
|
|
115
|
+
await cleanupSyncedCredentialKeysIfUnused(
|
|
116
|
+
{ ownerEmail: "admin@example.test", orgId: "org_123" },
|
|
117
|
+
["SHARED_API_KEY"],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
expect(mocks.deleteAppSecret).not.toHaveBeenCalled();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("can scan synced app secrets to recover stale keys after a retry", async () => {
|
|
124
|
+
mockVaultSecretLookup([]);
|
|
125
|
+
mocks.listAppSecretsForScope.mockResolvedValue([
|
|
126
|
+
{
|
|
127
|
+
key: "STALE_KEY",
|
|
128
|
+
description: "Synced from Dispatch vault: Old key",
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
key: "HAND_WRITTEN_KEY",
|
|
132
|
+
description: "Created manually",
|
|
133
|
+
},
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
await cleanupSyncedCredentialKeysIfUnused({
|
|
137
|
+
ownerEmail: "admin@example.test",
|
|
138
|
+
orgId: "org_123",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mocks.listAppSecretsForScope).toHaveBeenCalledWith("org", "org_123");
|
|
142
|
+
expect(mocks.deleteAppSecret).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(mocks.deleteAppSecret).toHaveBeenCalledWith({
|
|
144
|
+
key: "STALE_KEY",
|
|
145
|
+
scope: "org",
|
|
146
|
+
scopeId: "org_123",
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
|
3
3
|
import { discoverAgents } from "@agent-native/core/server/agent-discovery";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
deleteAppSecret,
|
|
6
|
+
listAppSecretsForScope,
|
|
7
|
+
writeAppSecret,
|
|
8
|
+
type SecretScope,
|
|
9
|
+
} from "@agent-native/core/secrets";
|
|
5
10
|
import {
|
|
6
11
|
getOrgSetting,
|
|
7
12
|
getUserSetting,
|
|
@@ -16,6 +21,7 @@ import {
|
|
|
16
21
|
} from "./dispatch-store.js";
|
|
17
22
|
|
|
18
23
|
const VAULT_ACCESS_SETTINGS_KEY = "dispatch-vault-access-settings";
|
|
24
|
+
const VAULT_SYNC_DESCRIPTION_PREFIX = "Synced from Dispatch vault:";
|
|
19
25
|
|
|
20
26
|
export type VaultAccessMode = "all-apps" | "manual";
|
|
21
27
|
|
|
@@ -300,16 +306,63 @@ export async function createSecret(
|
|
|
300
306
|
|
|
301
307
|
export async function updateSecret(
|
|
302
308
|
secretId: string,
|
|
303
|
-
|
|
309
|
+
input:
|
|
310
|
+
| string
|
|
311
|
+
| {
|
|
312
|
+
credentialKey?: string;
|
|
313
|
+
value?: string;
|
|
314
|
+
name?: string;
|
|
315
|
+
provider?: string | null;
|
|
316
|
+
description?: string | null;
|
|
317
|
+
},
|
|
304
318
|
ctx: VaultCtx = requireVaultCtx(),
|
|
305
319
|
) {
|
|
306
320
|
const db = getDb();
|
|
307
321
|
const existing = await getSecret(secretId, ctx);
|
|
308
322
|
if (!existing) throw new Error("Secret not found");
|
|
323
|
+
const patch = typeof input === "string" ? { value: input } : input;
|
|
324
|
+
const credentialKey =
|
|
325
|
+
patch.credentialKey !== undefined
|
|
326
|
+
? normalizeCredentialKey(patch.credentialKey)
|
|
327
|
+
: existing.credentialKey;
|
|
328
|
+
if (!credentialKey) throw new Error("Credential key is required");
|
|
329
|
+
const name = patch.name !== undefined ? patch.name.trim() : existing.name;
|
|
330
|
+
if (!name) throw new Error("Secret name is required");
|
|
331
|
+
const value = patch.value !== undefined ? patch.value : existing.value;
|
|
332
|
+
if (!value) throw new Error("Secret value is required");
|
|
333
|
+
const provider =
|
|
334
|
+
patch.provider !== undefined ? patch.provider || null : existing.provider;
|
|
335
|
+
const description =
|
|
336
|
+
patch.description !== undefined
|
|
337
|
+
? patch.description || null
|
|
338
|
+
: existing.description;
|
|
339
|
+
|
|
340
|
+
if (credentialKey !== existing.credentialKey) {
|
|
341
|
+
const conflict = await db
|
|
342
|
+
.select({ id: schema.vaultSecrets.id })
|
|
343
|
+
.from(schema.vaultSecrets)
|
|
344
|
+
.where(
|
|
345
|
+
and(
|
|
346
|
+
eq(schema.vaultSecrets.credentialKey, credentialKey),
|
|
347
|
+
ctxScope(schema.vaultSecrets, ctx),
|
|
348
|
+
),
|
|
349
|
+
)
|
|
350
|
+
.limit(1);
|
|
351
|
+
if (conflict[0] && conflict[0].id !== secretId) {
|
|
352
|
+
throw new Error(`Credential key "${credentialKey}" is already in use`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
309
355
|
|
|
310
356
|
await db
|
|
311
357
|
.update(schema.vaultSecrets)
|
|
312
|
-
.set({
|
|
358
|
+
.set({
|
|
359
|
+
name,
|
|
360
|
+
credentialKey,
|
|
361
|
+
value,
|
|
362
|
+
provider,
|
|
363
|
+
description,
|
|
364
|
+
updatedAt: now(),
|
|
365
|
+
})
|
|
313
366
|
.where(
|
|
314
367
|
and(
|
|
315
368
|
eq(schema.vaultSecrets.id, secretId),
|
|
@@ -317,14 +370,45 @@ export async function updateSecret(
|
|
|
317
370
|
),
|
|
318
371
|
);
|
|
319
372
|
|
|
373
|
+
const auditMetadata = {
|
|
374
|
+
name,
|
|
375
|
+
previousName: name !== existing.name ? existing.name : undefined,
|
|
376
|
+
credentialKey,
|
|
377
|
+
previousCredentialKey:
|
|
378
|
+
credentialKey !== existing.credentialKey
|
|
379
|
+
? existing.credentialKey
|
|
380
|
+
: undefined,
|
|
381
|
+
provider,
|
|
382
|
+
previousProvider:
|
|
383
|
+
provider !== existing.provider ? existing.provider : undefined,
|
|
384
|
+
description,
|
|
385
|
+
previousDescription:
|
|
386
|
+
description !== existing.description ? existing.description : undefined,
|
|
387
|
+
valueChanged: value !== existing.value ? true : undefined,
|
|
388
|
+
};
|
|
389
|
+
|
|
320
390
|
await recordVaultAudit({
|
|
321
391
|
action: "secret.updated",
|
|
322
392
|
secretId,
|
|
323
|
-
summary: `Updated
|
|
393
|
+
summary: `Updated secret "${name}" (${credentialKey})`,
|
|
394
|
+
metadata: auditMetadata,
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
await recordAudit({
|
|
398
|
+
action: "vault.secret.updated",
|
|
399
|
+
targetType: "vault-secret",
|
|
400
|
+
targetId: secretId,
|
|
401
|
+
summary: `Updated vault secret "${name}" (${credentialKey})`,
|
|
402
|
+
metadata: auditMetadata,
|
|
324
403
|
});
|
|
325
404
|
|
|
326
405
|
const updated = await getSecret(secretId, ctx);
|
|
327
406
|
if (updated) await syncSecretsToCredentialStore([updated], ctx);
|
|
407
|
+
if (updated && credentialKey !== existing.credentialKey) {
|
|
408
|
+
await cleanupSyncedCredentialKeysIfUnused(ctx, [existing.credentialKey]);
|
|
409
|
+
} else if (patch.credentialKey !== undefined) {
|
|
410
|
+
await cleanupSyncedCredentialKeysIfUnused(ctx);
|
|
411
|
+
}
|
|
328
412
|
return updated;
|
|
329
413
|
}
|
|
330
414
|
|
|
@@ -352,6 +436,7 @@ export async function deleteSecret(
|
|
|
352
436
|
ctxScope(schema.vaultSecrets, ctx),
|
|
353
437
|
),
|
|
354
438
|
);
|
|
439
|
+
await cleanupSyncedCredentialKeysIfUnused(ctx, [existing.credentialKey]);
|
|
355
440
|
|
|
356
441
|
await recordVaultAudit({
|
|
357
442
|
action: "secret.deleted",
|
|
@@ -555,7 +640,7 @@ export async function syncSecretsToCredentialStore(
|
|
|
555
640
|
value: secret.value,
|
|
556
641
|
scope: target.scope,
|
|
557
642
|
scopeId: target.scopeId,
|
|
558
|
-
description:
|
|
643
|
+
description: `${VAULT_SYNC_DESCRIPTION_PREFIX} ${secret.name}`,
|
|
559
644
|
});
|
|
560
645
|
syncedKeys.push(secret.credentialKey);
|
|
561
646
|
}
|
|
@@ -563,6 +648,41 @@ export async function syncSecretsToCredentialStore(
|
|
|
563
648
|
return { ...target, keys: syncedKeys };
|
|
564
649
|
}
|
|
565
650
|
|
|
651
|
+
export async function cleanupSyncedCredentialKeysIfUnused(
|
|
652
|
+
ctx: VaultCtx,
|
|
653
|
+
candidateKeys?: string[],
|
|
654
|
+
) {
|
|
655
|
+
const db = getDb();
|
|
656
|
+
const target = credentialStoreScopeForVaultCtx(ctx);
|
|
657
|
+
const keys = candidateKeys
|
|
658
|
+
? candidateKeys
|
|
659
|
+
: (await listAppSecretsForScope(target.scope, target.scopeId))
|
|
660
|
+
.filter((secret) =>
|
|
661
|
+
secret.description?.startsWith(VAULT_SYNC_DESCRIPTION_PREFIX),
|
|
662
|
+
)
|
|
663
|
+
.map((secret) => secret.key);
|
|
664
|
+
|
|
665
|
+
for (const key of new Set(keys.filter(Boolean))) {
|
|
666
|
+
const stillUsesKey = await db
|
|
667
|
+
.select({ id: schema.vaultSecrets.id })
|
|
668
|
+
.from(schema.vaultSecrets)
|
|
669
|
+
.where(
|
|
670
|
+
and(
|
|
671
|
+
eq(schema.vaultSecrets.credentialKey, key),
|
|
672
|
+
ctxScope(schema.vaultSecrets, ctx),
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
.limit(1);
|
|
676
|
+
if (!stillUsesKey[0]) {
|
|
677
|
+
await deleteAppSecret({
|
|
678
|
+
key,
|
|
679
|
+
scope: target.scope,
|
|
680
|
+
scopeId: target.scopeId,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
566
686
|
// ─── Sync ──────────────────────────────────────────────────────
|
|
567
687
|
|
|
568
688
|
export async function syncGrantsToApp(
|