@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.
Files changed (39) hide show
  1. package/dist/actions/update-vault-secret.d.ts.map +1 -1
  2. package/dist/actions/update-vault-secret.js +34 -6
  3. package/dist/actions/update-vault-secret.js.map +1 -1
  4. package/dist/routes/index.d.ts.map +1 -1
  5. package/dist/routes/index.js +1 -0
  6. package/dist/routes/index.js.map +1 -1
  7. package/dist/routes/pages/extensions.$id.d.ts +3 -0
  8. package/dist/routes/pages/extensions.$id.d.ts.map +1 -1
  9. package/dist/routes/pages/extensions.$id.js +5 -2
  10. package/dist/routes/pages/extensions.$id.js.map +1 -1
  11. package/dist/routes/pages/extensions._index.d.ts +3 -0
  12. package/dist/routes/pages/extensions._index.d.ts.map +1 -1
  13. package/dist/routes/pages/extensions._index.js +5 -2
  14. package/dist/routes/pages/extensions._index.js.map +1 -1
  15. package/dist/routes/pages/tools.$id.d.ts +3 -0
  16. package/dist/routes/pages/tools.$id.d.ts.map +1 -1
  17. package/dist/routes/pages/tools.$id.js +3 -0
  18. package/dist/routes/pages/tools.$id.js.map +1 -1
  19. package/dist/routes/pages/tools._index.d.ts +3 -0
  20. package/dist/routes/pages/tools._index.d.ts.map +1 -1
  21. package/dist/routes/pages/tools._index.js +3 -0
  22. package/dist/routes/pages/tools._index.js.map +1 -1
  23. package/dist/routes/pages/vault.d.ts.map +1 -1
  24. package/dist/routes/pages/vault.js +46 -2
  25. package/dist/routes/pages/vault.js.map +1 -1
  26. package/dist/server/lib/vault-store.d.ts +8 -1
  27. package/dist/server/lib/vault-store.d.ts.map +1 -1
  28. package/dist/server/lib/vault-store.js +90 -5
  29. package/dist/server/lib/vault-store.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/actions/update-vault-secret.ts +39 -6
  32. package/src/routes/index.ts +1 -0
  33. package/src/routes/pages/extensions.$id.tsx +6 -2
  34. package/src/routes/pages/extensions._index.tsx +6 -2
  35. package/src/routes/pages/tools.$id.tsx +4 -0
  36. package/src/routes/pages/tools._index.tsx +4 -0
  37. package/src/routes/pages/vault.tsx +172 -1
  38. package/src/server/lib/vault-store.spec.ts +80 -0
  39. 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 { writeAppSecret, type SecretScope } from "@agent-native/core/secrets";
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
- value: string,
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({ value, updatedAt: now() })
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 value for secret "${existing.name}" (${existing.credentialKey})`,
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: `Synced from Dispatch vault: ${secret.name}`,
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(