@agent-native/core 0.228.2-nightly-20261113152307 → 0.228.2

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 (34) hide show
  1. package/dist/cli/skills-content/an-skill.d.ts +1 -1
  2. package/dist/cli/skills-content/an-skill.js +16 -0
  3. package/dist/collab/routes.d.ts +1 -1
  4. package/dist/collab/struct-routes.d.ts +1 -1
  5. package/dist/db/schema-snapshot.generated.js +2 -1
  6. package/dist/framework-package-versions.json +11 -11
  7. package/dist/identity/rekey.js +2 -0
  8. package/dist/mcp/embed-app.js +259 -3
  9. package/dist/mcp/oauth-route.js +7 -0
  10. package/dist/notifications/routes.d.ts +3 -3
  11. package/dist/observability/routes.d.ts +5 -5
  12. package/dist/secrets/api-keys.js +8 -7
  13. package/dist/secrets/index.d.ts +2 -1
  14. package/dist/secrets/index.js +2 -1
  15. package/dist/secrets/routes.d.ts +4 -4
  16. package/dist/secrets/routes.js +7 -8
  17. package/dist/secrets/schema.d.ts +51 -0
  18. package/dist/secrets/schema.js +3 -0
  19. package/dist/secrets/storage.d.ts +83 -1
  20. package/dist/secrets/storage.js +194 -6
  21. package/dist/secrets/usage.js +2 -4
  22. package/dist/secrets/vault-managed.d.ts +17 -0
  23. package/dist/secrets/vault-managed.js +19 -0
  24. package/dist/server/agent-engine-api-key-route.d.ts +2 -2
  25. package/dist/server/builder-signup-experiment.d.ts +4 -4
  26. package/dist/server/credential-provider.js +7 -7
  27. package/dist/server/first-touch-route.d.ts +1 -1
  28. package/dist/server/transcribe-voice.d.ts +1 -1
  29. package/dist/templates/workspace-core/.agents/skills/external-agents/references/mcp-apps-embedding.md +11 -0
  30. package/docs/content/security.mdx +1 -1
  31. package/docs/content/template-dispatch-developers.mdx +7 -7
  32. package/docs/content/template-dispatch-reference.mdx +16 -10
  33. package/package.json +4 -4
  34. package/src/templates/workspace-core/.agents/skills/external-agents/references/mcp-apps-embedding.md +11 -0
@@ -51,17 +51,16 @@ import { PERSONAL_PROVIDER_KEYS_RESTRICTED_ERROR_CODE, resolvePersonalProviderKe
51
51
  import { canonicalSecretKey, secretKeyNames } from "./key-aliases.js";
52
52
  import { isManagedDeleteAllowed, managedDeleteRefusal, resolveSecretManagedBy, } from "./managed-keys.js";
53
53
  import { listRequiredSecrets, getRequiredSecret, } from "./register.js";
54
- import { writeAppSecret, updateAppSecretUrlAllowlist, deleteAppSecret, last4, listAppSecretsForScope, readAppSecretMeta, VAULT_SYNC_DESCRIPTION_PREFIX, } from "./storage.js";
54
+ import { writeAppSecret, updateAppSecretUrlAllowlist, deleteAppSecret, last4, listAppSecretsForScope, readAppSecretMeta, } from "./storage.js";
55
55
  import { registeredUrlAllowlist, validateUrlAllowlist, } from "./substitution.js";
56
56
  import { describeSecretUsage } from "./usage.js";
57
+ import { isVaultManagedSecret } from "./vault-managed.js";
57
58
  export const SECRET_REJECTED_ERROR_CODE = "secret_rejected";
58
59
  export const SECRET_VALIDATION_UNAVAILABLE_ERROR_CODE = "secret_validation_unavailable";
59
- function secretSource(scope, description) {
60
+ function secretSource(scope, meta) {
60
61
  if (scope === "user")
61
62
  return "personal";
62
- return description?.startsWith(VAULT_SYNC_DESCRIPTION_PREFIX)
63
- ? "vault"
64
- : "workspace";
63
+ return isVaultManagedSecret(meta) ? "vault" : "workspace";
65
64
  }
66
65
  const NOT_RESOLVED = {
67
66
  value: null,
@@ -216,7 +215,7 @@ export function createListSecretsHandler() {
216
215
  const meta = await readAppSecretMeta(hit);
217
216
  base.last4 = meta?.last4 || last4(effective.value);
218
217
  base.updatedAt = meta?.updatedAt;
219
- base.source = secretSource(hit.scope, meta?.description);
218
+ base.source = secretSource(hit.scope, meta);
220
219
  base.managedHere = hit.scope === secret.scope && hit.scopeId === scopeId;
221
220
  if (base.managedHere && secret.scope === "user") {
222
221
  const shared = await asRequestUser(event, () => resolveSecretWithAliasesDetailed(secret.key, {
@@ -233,7 +232,7 @@ export function createListSecretsHandler() {
233
232
  scopeId: shared.scopeId,
234
233
  })
235
234
  : null;
236
- const sharedSource = secretSource(shared.source, sharedMeta?.description);
235
+ const sharedSource = secretSource(shared.source, sharedMeta);
237
236
  base.overrides = sharedSource === "vault" ? "vault" : "workspace";
238
237
  }
239
238
  }
@@ -513,7 +512,7 @@ function metaToPayload(meta) {
513
512
  name: meta.key,
514
513
  scope: meta.scope,
515
514
  scopeId: meta.scopeId,
516
- source: secretSource(meta.scope, meta.description),
515
+ source: secretSource(meta.scope, meta),
517
516
  description: meta.description,
518
517
  last4: meta.last4,
519
518
  urlAllowlist: meta.urlAllowlist,
@@ -138,6 +138,57 @@ export declare const appSecrets: import("drizzle-orm/pg-core").PgTableWithColumn
138
138
  identity: undefined;
139
139
  generated: undefined;
140
140
  }, {}, {}>;
141
+ label: import("drizzle-orm/pg-core").PgColumn<{
142
+ name: "label";
143
+ tableName: "app_secrets";
144
+ dataType: "string";
145
+ columnType: "PgText";
146
+ data: string;
147
+ driverParam: string;
148
+ notNull: false;
149
+ hasDefault: false;
150
+ isPrimaryKey: false;
151
+ isAutoincrement: false;
152
+ hasRuntimeDefault: false;
153
+ enumValues: [string, ...string[]];
154
+ baseColumn: never;
155
+ identity: undefined;
156
+ generated: undefined;
157
+ }, {}, {}>;
158
+ provider: import("drizzle-orm/pg-core").PgColumn<{
159
+ name: "provider";
160
+ tableName: "app_secrets";
161
+ dataType: "string";
162
+ columnType: "PgText";
163
+ data: string;
164
+ driverParam: string;
165
+ notNull: false;
166
+ hasDefault: false;
167
+ isPrimaryKey: false;
168
+ isAutoincrement: false;
169
+ hasRuntimeDefault: false;
170
+ enumValues: [string, ...string[]];
171
+ baseColumn: never;
172
+ identity: undefined;
173
+ generated: undefined;
174
+ }, {}, {}>;
175
+ createdBy: import("drizzle-orm/pg-core").PgColumn<{
176
+ name: "created_by";
177
+ tableName: "app_secrets";
178
+ dataType: "string";
179
+ columnType: "PgText";
180
+ data: string;
181
+ driverParam: string;
182
+ notNull: false;
183
+ hasDefault: false;
184
+ isPrimaryKey: false;
185
+ isAutoincrement: false;
186
+ hasRuntimeDefault: false;
187
+ enumValues: [string, ...string[]];
188
+ baseColumn: never;
189
+ identity: undefined;
190
+ generated: undefined;
191
+ }, {}, {}>;
141
192
  createdAt: import("drizzle-orm/pg-core").PgColumn<{
142
193
  name: "created_at";
143
194
  tableName: "app_secrets";
@@ -8,6 +8,9 @@ export const appSecrets = table("app_secrets", {
8
8
  sharedEncryptedValue: text("shared_encrypted_value"),
9
9
  description: text("description"),
10
10
  urlAllowlist: text("url_allowlist"),
11
+ label: text("label"),
12
+ provider: text("provider"),
13
+ createdBy: text("created_by"),
11
14
  createdAt: bigint("created_at", { mode: "number" }).notNull(),
12
15
  updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
13
16
  });
@@ -18,9 +18,10 @@
18
18
  * Secret values are NEVER logged and NEVER returned from any route handler.
19
19
  */
20
20
  import type { SecretScope } from "./register.js";
21
+ import { VAULT_SYNC_DESCRIPTION_PREFIX } from "./vault-managed.js";
22
+ export { VAULT_SYNC_DESCRIPTION_PREFIX };
21
23
  export declare const appSecretsStore: import("../db/store-registry.js").Store;
22
24
  export declare function ensureTable(): Promise<void>;
23
- export declare const VAULT_SYNC_DESCRIPTION_PREFIX = "Synced from Dispatch vault:";
24
25
  export declare function last4(value: string): string;
25
26
  export interface SecretRef {
26
27
  key: string;
@@ -31,6 +32,19 @@ export interface WriteSecretArgs extends SecretRef {
31
32
  value: string;
32
33
  description?: string;
33
34
  urlAllowlist?: string;
35
+ /** Display name. A write that omits it keeps the stored one. */
36
+ label?: string | null;
37
+ /** Provider tag. A write that omits it keeps the stored one. */
38
+ provider?: string | null;
39
+ /**
40
+ * Who created the key through the Dispatch Vault, which marks the row as
41
+ * Vault-managed. The first creator is kept: later writes never replace it.
42
+ */
43
+ createdBy?: string | null;
44
+ }
45
+ export declare class AppSecretKeyConflictError extends Error {
46
+ readonly statusCode = 409;
47
+ constructor(key: string);
34
48
  }
35
49
  export declare class AppSecretKeyMismatchError extends Error {
36
50
  constructor(ref: SecretRef);
@@ -75,12 +89,29 @@ export declare function getAppSecretMeta(ref: SecretRef): Promise<{
75
89
  updatedAt: number;
76
90
  } | null>;
77
91
  export interface SecretMeta {
92
+ id: string;
78
93
  key: string;
79
94
  scope: SecretScope;
80
95
  scopeId: string;
81
96
  last4: string;
82
97
  description: string | null;
83
98
  urlAllowlist: string[] | null;
99
+ label: string | null;
100
+ provider: string | null;
101
+ createdBy: string | null;
102
+ createdAt: number;
103
+ updatedAt: number;
104
+ }
105
+ /** A stored key's metadata with nothing derived from its value. */
106
+ export interface AppSecretSummary {
107
+ id: string;
108
+ key: string;
109
+ scope: SecretScope;
110
+ scopeId: string;
111
+ description: string | null;
112
+ label: string | null;
113
+ provider: string | null;
114
+ createdBy: string | null;
84
115
  createdAt: number;
85
116
  updatedAt: number;
86
117
  }
@@ -97,6 +128,57 @@ export declare function readAppSecretMeta(ref: SecretRef): Promise<SecretMeta |
97
128
  * user-created keys.
98
129
  */
99
130
  export declare function listAppSecretsForScope(scope: SecretScope, scopeId: string): Promise<SecretMeta[]>;
131
+ /** The key's metadata by stored id, within one scope. Null when that scope holds no such row. */
132
+ export declare function readAppSecretMetaById(target: {
133
+ id: string;
134
+ scope: SecretScope;
135
+ scopeId: string;
136
+ }): Promise<SecretMeta | null>;
137
+ /**
138
+ * Every key in one scope with nothing derived from its value, so no
139
+ * decryption: the cheap read for pickers, counts and lookups. Use
140
+ * `listAppSecretsForScope` when the masked preview is needed.
141
+ */
142
+ export declare function listAppSecretSummaries(scope: SecretScope, scopeId: string): Promise<AppSecretSummary[]>;
143
+ export interface UpdateAppSecretPatch {
144
+ /** Rename the key; the row keeps its id, value and creator. */
145
+ key?: string;
146
+ /** Replace the value. Omit to leave the stored ciphertext untouched. */
147
+ value?: string;
148
+ label?: string | null;
149
+ provider?: string | null;
150
+ description?: string | null;
151
+ /**
152
+ * The Dispatch Vault is writing this row, so it is Vault-managed from now
153
+ * on. Recorded as the creator only when the row has none.
154
+ */
155
+ adoptedBy?: string;
156
+ }
157
+ /**
158
+ * Edit one stored key by its id within a scope, in one statement: rename,
159
+ * replace the value, and change the display fields together or alone. A field
160
+ * the patch leaves out keeps its stored value; `null` clears `label`,
161
+ * `provider` and `description`. Returns the key now stored and the one it
162
+ * replaced, or null when that scope holds no row with this id.
163
+ */
164
+ export declare function updateAppSecretById(target: {
165
+ id: string;
166
+ scope: SecretScope;
167
+ scopeId: string;
168
+ }, patch: UpdateAppSecretPatch): Promise<{
169
+ key: string;
170
+ previousKey: string;
171
+ } | null>;
172
+ /**
173
+ * Delete one row by its id within a scope. Unlike `deleteAppSecret` this cannot
174
+ * remove a different row that took over the key name after a rename. Returns
175
+ * whether a row was removed.
176
+ */
177
+ export declare function deleteAppSecretById(target: {
178
+ id: string;
179
+ scope: SecretScope;
180
+ scopeId: string;
181
+ }): Promise<boolean>;
100
182
  export declare function deleteAppSecret(ref: SecretRef): Promise<boolean>;
101
183
  /**
102
184
  * Delete only if the stored ciphertext is still the version the caller read.
@@ -29,6 +29,8 @@ import { encryptSecretValue as encryptLegacyValue, encryptSharedSecretValue as e
29
29
  import { AppSecretDecryptionError } from "./errors.js";
30
30
  import { invalidateOptionalKeyCache } from "./optional-key-cache.js";
31
31
  import { APP_SECRETS_CREATE_SQL } from "./schema.js";
32
+ import { VAULT_SYNC_DESCRIPTION_PREFIX } from "./vault-managed.js";
33
+ export { VAULT_SYNC_DESCRIPTION_PREFIX };
32
34
  export const appSecretsStore = defineStore({
33
35
  id: "app_secrets",
34
36
  migrations: [
@@ -42,12 +44,19 @@ export const appSecretsStore = defineStore({
42
44
  await ensureColumnExists("app_secrets", "shared_encrypted_value", `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS shared_encrypted_value TEXT`);
43
45
  },
44
46
  },
47
+ {
48
+ name: "vault-metadata-columns",
49
+ run: async () => {
50
+ await ensureColumnExists("app_secrets", "label", `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS label TEXT`);
51
+ await ensureColumnExists("app_secrets", "provider", `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS provider TEXT`);
52
+ await ensureColumnExists("app_secrets", "created_by", `ALTER TABLE app_secrets ADD COLUMN IF NOT EXISTS created_by TEXT`);
53
+ },
54
+ },
45
55
  ],
46
56
  });
47
57
  export function ensureTable() {
48
58
  return appSecretsStore.ready();
49
59
  }
50
- export const VAULT_SYNC_DESCRIPTION_PREFIX = "Synced from Dispatch vault:";
51
60
  export function last4(value) {
52
61
  if (!value)
53
62
  return "";
@@ -55,6 +64,13 @@ export function last4(value) {
55
64
  return "••••";
56
65
  return "••••" + value.slice(-4);
57
66
  }
67
+ export class AppSecretKeyConflictError extends Error {
68
+ statusCode = 409;
69
+ constructor(key) {
70
+ super(`Credential key "${key}" is already in use`);
71
+ this.name = "AppSecretKeyConflictError";
72
+ }
73
+ }
58
74
  export class AppSecretKeyMismatchError extends Error {
59
75
  constructor(ref) {
60
76
  super(`Refusing to write secret "${ref.key}" (${ref.scope} scope): this development process is connected to a shared database, and the secrets stored there use encryption key material it does not prefer. Set WORKSPACE_SECRETS_ENCRYPTION_KEY to match the deployments that use this database, or point DATABASE_URL at a local database.`);
@@ -102,7 +118,7 @@ async function assertWriteKeepsSecretsReadable(ref) {
102
118
  */
103
119
  export async function writeAppSecret(args) {
104
120
  await ensureTable();
105
- const { key, value, scope, scopeId, description, urlAllowlist } = args;
121
+ const { key, value, scope, scopeId, description, urlAllowlist, label, provider, createdBy, } = args;
106
122
  if (!key || !value || !scope || !scopeId) {
107
123
  throw new Error("writeAppSecret: key, value, scope, and scopeId are all required");
108
124
  }
@@ -138,13 +154,20 @@ export async function writeAppSecret(args) {
138
154
  // `populateSharedAppSecret`, which fills a NULL column or compare-and-swap
139
155
  // replaces the exact legacy ciphertext it just decrypted. A temporary miss
140
156
  // is safer than serving rotated-away plaintext.
141
- const upsertSql = `INSERT INTO app_secrets (id, scope, scope_id, key, encrypted_value, shared_encrypted_value, description, url_allowlist, created_at, updated_at)
142
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
157
+ //
158
+ // label, provider and created_by follow the opposite rule: a write that does
159
+ // not pass them (every writer but the Dispatch Vault) leaves the stored
160
+ // values alone, and the first creator is never replaced.
161
+ const upsertSql = `INSERT INTO app_secrets (id, scope, scope_id, key, encrypted_value, shared_encrypted_value, description, url_allowlist, label, provider, created_by, created_at, updated_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
143
163
  ON CONFLICT (scope, scope_id, key) DO UPDATE SET
144
164
  encrypted_value = excluded.encrypted_value,
145
165
  shared_encrypted_value = excluded.shared_encrypted_value,
146
166
  description = excluded.description,
147
167
  url_allowlist = COALESCE(excluded.url_allowlist, app_secrets.url_allowlist),
168
+ label = COALESCE(excluded.label, app_secrets.label),
169
+ provider = COALESCE(excluded.provider, app_secrets.provider),
170
+ created_by = COALESCE(app_secrets.created_by, excluded.created_by),
148
171
  updated_at = excluded.updated_at`;
149
172
  const upsertArgs = [
150
173
  id,
@@ -155,6 +178,9 @@ export async function writeAppSecret(args) {
155
178
  sharedEncrypted,
156
179
  description ?? null,
157
180
  urlAllowlist ?? null,
181
+ label ?? null,
182
+ provider ?? null,
183
+ createdBy ?? null,
158
184
  now,
159
185
  now,
160
186
  ];
@@ -173,6 +199,7 @@ export async function updateAppSecretUrlAllowlist(ref, origins) {
173
199
  const { rows } = await getDbExec().execute({
174
200
  sql: `UPDATE app_secrets SET url_allowlist = ?, updated_at = ?
175
201
  WHERE scope = ? AND scope_id = ? AND key = ?
202
+ AND created_by IS NULL
176
203
  AND (description IS NULL OR description NOT LIKE ?)
177
204
  RETURNING id`,
178
205
  args: [
@@ -494,7 +521,7 @@ export async function readAppSecretMeta(ref) {
494
521
  const { key, scope, scopeId } = ref;
495
522
  const client = getDbExec();
496
523
  const { rows } = await client.execute({
497
- sql: `SELECT id, encrypted_value, shared_encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,
524
+ sql: `SELECT id, encrypted_value, shared_encrypted_value, description, url_allowlist, label, provider, created_by, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,
498
525
  args: [scope, scopeId, key],
499
526
  });
500
527
  if (rows.length === 0)
@@ -513,12 +540,16 @@ export async function readAppSecretMeta(ref) {
513
540
  last4Value = "";
514
541
  }
515
542
  return {
543
+ id: String(row.id),
516
544
  key,
517
545
  scope,
518
546
  scopeId,
519
547
  last4: last4Value,
520
548
  description: row.description ?? null,
521
549
  urlAllowlist: parseAllowlist(row.url_allowlist),
550
+ label: row.label ?? null,
551
+ provider: row.provider ?? null,
552
+ createdBy: row.created_by ?? null,
522
553
  createdAt: Number(row.created_at ?? 0),
523
554
  updatedAt: Number(row.updated_at ?? 0),
524
555
  };
@@ -532,7 +563,7 @@ export async function listAppSecretsForScope(scope, scopeId) {
532
563
  await ensureTable();
533
564
  const client = getDbExec();
534
565
  const { rows } = await client.execute({
535
- sql: `SELECT id, key, encrypted_value, shared_encrypted_value, description, url_allowlist, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? ORDER BY updated_at DESC`,
566
+ sql: `SELECT id, key, encrypted_value, shared_encrypted_value, description, url_allowlist, label, provider, created_by, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? ORDER BY updated_at DESC`,
536
567
  args: [scope, scopeId],
537
568
  });
538
569
  const results = [];
@@ -550,18 +581,175 @@ export async function listAppSecretsForScope(scope, scopeId) {
550
581
  last4Value = "";
551
582
  }
552
583
  results.push({
584
+ id: String(row.id),
553
585
  key: row.key,
554
586
  scope,
555
587
  scopeId,
556
588
  last4: last4Value,
557
589
  description: row.description ?? null,
558
590
  urlAllowlist: parseAllowlist(row.url_allowlist),
591
+ label: row.label ?? null,
592
+ provider: row.provider ?? null,
593
+ createdBy: row.created_by ?? null,
559
594
  createdAt: Number(row.created_at ?? 0),
560
595
  updatedAt: Number(row.updated_at ?? 0),
561
596
  });
562
597
  }
563
598
  return results;
564
599
  }
600
+ /** The key's metadata by stored id, within one scope. Null when that scope holds no such row. */
601
+ export async function readAppSecretMetaById(target) {
602
+ await ensureTable();
603
+ const { rows } = await getDbExec().execute({
604
+ sql: `SELECT key FROM app_secrets WHERE id = ? AND scope = ? AND scope_id = ? LIMIT 1`,
605
+ args: [target.id, target.scope, target.scopeId],
606
+ });
607
+ if (rows.length === 0)
608
+ return null;
609
+ return readAppSecretMeta({
610
+ key: String(rows[0].key),
611
+ scope: target.scope,
612
+ scopeId: target.scopeId,
613
+ });
614
+ }
615
+ /**
616
+ * Every key in one scope with nothing derived from its value, so no
617
+ * decryption: the cheap read for pickers, counts and lookups. Use
618
+ * `listAppSecretsForScope` when the masked preview is needed.
619
+ */
620
+ export async function listAppSecretSummaries(scope, scopeId) {
621
+ await ensureTable();
622
+ const { rows } = await getDbExec().execute({
623
+ sql: `SELECT id, key, description, label, provider, created_by, created_at, updated_at FROM app_secrets WHERE scope = ? AND scope_id = ? ORDER BY updated_at DESC`,
624
+ args: [scope, scopeId],
625
+ });
626
+ return rows.map((row) => ({
627
+ id: String(row.id),
628
+ key: String(row.key),
629
+ scope,
630
+ scopeId,
631
+ description: row.description ?? null,
632
+ label: row.label ?? null,
633
+ provider: row.provider ?? null,
634
+ createdBy: row.created_by ?? null,
635
+ createdAt: Number(row.created_at ?? 0),
636
+ updatedAt: Number(row.updated_at ?? 0),
637
+ }));
638
+ }
639
+ /**
640
+ * Edit one stored key by its id within a scope, in one statement: rename,
641
+ * replace the value, and change the display fields together or alone. A field
642
+ * the patch leaves out keeps its stored value; `null` clears `label`,
643
+ * `provider` and `description`. Returns the key now stored and the one it
644
+ * replaced, or null when that scope holds no row with this id.
645
+ */
646
+ export async function updateAppSecretById(target, patch) {
647
+ await ensureTable();
648
+ const client = getDbExec();
649
+ const { rows } = await client.execute({
650
+ sql: `SELECT key FROM app_secrets WHERE id = ? AND scope = ? AND scope_id = ? LIMIT 1`,
651
+ args: [target.id, target.scope, target.scopeId],
652
+ });
653
+ if (rows.length === 0)
654
+ return null;
655
+ const previousKey = String(rows[0].key);
656
+ const key = patch.key ?? previousKey;
657
+ if (!key)
658
+ throw new Error("updateAppSecretById: key cannot be empty");
659
+ if (patch.value !== undefined && !patch.value) {
660
+ throw new Error("updateAppSecretById: value cannot be empty");
661
+ }
662
+ if (key !== previousKey) {
663
+ const { rows: clash } = await client.execute({
664
+ sql: `SELECT 1 FROM app_secrets WHERE scope = ? AND scope_id = ? AND key = ? LIMIT 1`,
665
+ args: [target.scope, target.scopeId, key],
666
+ });
667
+ if (clash.length > 0)
668
+ throw new AppSecretKeyConflictError(key);
669
+ }
670
+ let encrypted = null;
671
+ let sharedEncrypted = null;
672
+ if (patch.value !== undefined) {
673
+ await assertWriteKeepsSecretsReadable({
674
+ key,
675
+ scope: target.scope,
676
+ scopeId: target.scopeId,
677
+ });
678
+ encrypted = encryptLegacyValue(patch.value);
679
+ sharedEncrypted = hasSharedSecretEncryptionKeyMaterial()
680
+ ? encryptValue(patch.value)
681
+ : null;
682
+ }
683
+ // Each CASE flag says whether the patch carries that field: a field left out
684
+ // keeps its stored value, and an explicit null clears it.
685
+ const { rows: updated } = await client.execute({
686
+ sql: `UPDATE app_secrets SET
687
+ key = CASE WHEN ? THEN ? ELSE key END,
688
+ encrypted_value = CASE WHEN ? THEN ? ELSE encrypted_value END,
689
+ shared_encrypted_value = CASE WHEN ? THEN ? ELSE shared_encrypted_value END,
690
+ label = CASE WHEN ? THEN ? ELSE label END,
691
+ provider = CASE WHEN ? THEN ? ELSE provider END,
692
+ description = CASE WHEN ? THEN ? ELSE description END,
693
+ created_by = COALESCE(created_by, ?),
694
+ updated_at = ?
695
+ WHERE id = ? AND scope = ? AND scope_id = ?
696
+ RETURNING id`,
697
+ args: [
698
+ patch.key !== undefined,
699
+ patch.key ?? null,
700
+ patch.value !== undefined,
701
+ encrypted,
702
+ patch.value !== undefined,
703
+ sharedEncrypted,
704
+ patch.label !== undefined,
705
+ patch.label ?? null,
706
+ patch.provider !== undefined,
707
+ patch.provider ?? null,
708
+ patch.description !== undefined,
709
+ patch.description ?? null,
710
+ patch.adoptedBy ?? null,
711
+ Date.now(),
712
+ target.id,
713
+ target.scope,
714
+ target.scopeId,
715
+ ],
716
+ });
717
+ if (updated.length === 0)
718
+ return null;
719
+ for (const staleKey of new Set([previousKey, key])) {
720
+ invalidateRequestSecret({
721
+ key: staleKey,
722
+ scope: target.scope,
723
+ scopeId: target.scopeId,
724
+ });
725
+ }
726
+ invalidateOptionalKeyCache();
727
+ invalidateAgentEngineStatusCache();
728
+ return { key, previousKey };
729
+ }
730
+ /**
731
+ * Delete one row by its id within a scope. Unlike `deleteAppSecret` this cannot
732
+ * remove a different row that took over the key name after a rename. Returns
733
+ * whether a row was removed.
734
+ */
735
+ export async function deleteAppSecretById(target) {
736
+ await ensureTable();
737
+ const client = getDbExec();
738
+ const { rows } = await client.execute({
739
+ sql: `DELETE FROM app_secrets WHERE id = ? AND scope = ? AND scope_id = ? RETURNING key`,
740
+ args: [target.id, target.scope, target.scopeId],
741
+ });
742
+ if (rows.length === 0)
743
+ return false;
744
+ invalidateRequestSecret({
745
+ key: String(rows[0].key),
746
+ scope: target.scope,
747
+ scopeId: target.scopeId,
748
+ });
749
+ invalidateOptionalKeyCache();
750
+ invalidateAgentEngineStatusCache();
751
+ return true;
752
+ }
565
753
  function parseAllowlist(raw) {
566
754
  if (raw === null)
567
755
  return null;
@@ -10,7 +10,7 @@ import { AGENT_PROVIDER_CATALOG, providerIdForEngine, } from "../client/agent-pr
10
10
  import { getRequestOrgId } from "../server/request-context.js";
11
11
  import { managedSecretUsage, resolveSecretManagedBy } from "./managed-keys.js";
12
12
  import { getRegisteredSecretUsage, getRequiredSecret, } from "./register.js";
13
- import { VAULT_SYNC_DESCRIPTION_PREFIX } from "./storage.js";
13
+ import { isVaultManagedSecret } from "./vault-managed.js";
14
14
  /** App id used in effects that apply to every app. */
15
15
  export const ALL_APPS = "all";
16
16
  function providerForKey(key) {
@@ -73,9 +73,7 @@ async function resolveSharedFallback(key) {
73
73
  : null;
74
74
  return {
75
75
  status: "shared",
76
- source: meta?.description?.startsWith(VAULT_SYNC_DESCRIPTION_PREFIX)
77
- ? "vault"
78
- : shared.source,
76
+ source: isVaultManagedSecret(meta) ? "vault" : shared.source,
79
77
  };
80
78
  }
81
79
  function engineDisplayLabel(entry) {
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Marker on `app_secrets.description` that releases before the Vault moved
3
+ * onto `app_secrets` wrote on every Vault-owned row. New Vault writes mark a
4
+ * row with `created_by` instead: such a release deletes marked rows that its
5
+ * own Vault table has no entry for, and it must never find a new key that way.
6
+ */
7
+ export declare const VAULT_SYNC_DESCRIPTION_PREFIX = "Synced from Dispatch vault:";
8
+ /**
9
+ * True for a key the Dispatch Vault owns: created or edited through the Vault
10
+ * (`created_by` is set), or written by a release that marked it in the
11
+ * description. Rows an app registered for itself, and a person's own keys, are
12
+ * neither. Callers rule out `user` scope themselves.
13
+ */
14
+ export declare function isVaultManagedSecret(meta: {
15
+ description?: string | null | undefined;
16
+ createdBy?: string | null | undefined;
17
+ } | null | undefined): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Marker on `app_secrets.description` that releases before the Vault moved
3
+ * onto `app_secrets` wrote on every Vault-owned row. New Vault writes mark a
4
+ * row with `created_by` instead: such a release deletes marked rows that its
5
+ * own Vault table has no entry for, and it must never find a new key that way.
6
+ */
7
+ export const VAULT_SYNC_DESCRIPTION_PREFIX = "Synced from Dispatch vault:";
8
+ /**
9
+ * True for a key the Dispatch Vault owns: created or edited through the Vault
10
+ * (`created_by` is set), or written by a release that marked it in the
11
+ * description. Rows an app registered for itself, and a person's own keys, are
12
+ * neither. Callers rule out `user` scope themselves.
13
+ */
14
+ export function isVaultManagedSecret(meta) {
15
+ if (!meta)
16
+ return false;
17
+ return (!!meta.createdBy ||
18
+ !!meta.description?.startsWith(VAULT_SYNC_DESCRIPTION_PREFIX));
19
+ }
@@ -54,9 +54,9 @@ export declare function resolveAgentEngineApiKeyWriteTarget(event: H3Event, scop
54
54
  error: string;
55
55
  }>;
56
56
  export declare function createAgentEngineApiKeyHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
57
- errorCode?: undefined;
58
57
  code?: undefined;
59
58
  error: string;
59
+ errorCode?: undefined;
60
60
  } | {
61
61
  code?: undefined;
62
62
  error: string;
@@ -67,8 +67,8 @@ export declare function createAgentEngineApiKeyHandler(): import("h3").EventHand
67
67
  code: ProviderKeyCheckCode;
68
68
  } | {
69
69
  error?: undefined;
70
- errorCode?: undefined;
71
70
  code?: undefined;
71
+ errorCode?: undefined;
72
72
  ok: boolean;
73
73
  key: string;
74
74
  baseUrlKey?: string | undefined;
@@ -66,9 +66,10 @@ export declare function createBuilderSignupExperiment(dependencies: {
66
66
  status: "unavailable";
67
67
  code: string;
68
68
  } | {
69
- status: "disabled";
70
69
  code?: undefined;
70
+ status: "disabled";
71
71
  } | {
72
+ code?: undefined;
72
73
  id: string;
73
74
  variant: "automatic" | "control";
74
75
  issuedAt: number;
@@ -79,7 +80,6 @@ export declare function createBuilderSignupExperiment(dependencies: {
79
80
  policy: "builder-signup-2026-11-06";
80
81
  status: "assigned";
81
82
  experimentId: string;
82
- code?: undefined;
83
83
  } | {
84
84
  status: "disabled";
85
85
  code: string;
@@ -106,9 +106,10 @@ export declare function createBuilderSignupExperiment(dependencies: {
106
106
  status: "unavailable";
107
107
  code: string;
108
108
  } | {
109
- status: "disabled";
110
109
  code?: undefined;
110
+ status: "disabled";
111
111
  } | {
112
+ code?: undefined;
112
113
  id: string;
113
114
  variant: "automatic" | "control";
114
115
  issuedAt: number;
@@ -119,7 +120,6 @@ export declare function createBuilderSignupExperiment(dependencies: {
119
120
  policy: "builder-signup-2026-11-06";
120
121
  status: "assigned";
121
122
  experimentId: string;
122
- code?: undefined;
123
123
  } | {
124
124
  status: "disabled";
125
125
  code: string;