@agent-native/core 0.228.2-nightly-20261113032548 → 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.
- package/dist/application-state/handlers.d.ts +2 -2
- package/dist/cli/skills-content/an-skill.d.ts +1 -1
- package/dist/cli/skills-content/an-skill.js +16 -0
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/schema-snapshot.generated.js +2 -1
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/file-upload/builder-gateway.d.ts +42 -0
- package/dist/file-upload/builder-gateway.js +185 -0
- package/dist/file-upload/builder.js +8 -11
- package/dist/file-upload/index.d.ts +1 -0
- package/dist/file-upload/index.js +1 -0
- package/dist/framework-package-versions.json +11 -11
- package/dist/identity/rekey.js +2 -0
- package/dist/mcp/embed-app.js +259 -3
- package/dist/mcp/oauth-route.js +7 -0
- package/dist/mcp/screen-memory-stdio.d.ts +3 -3
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/org/index.d.ts +1 -1
- package/dist/secrets/api-keys.js +8 -7
- package/dist/secrets/index.d.ts +2 -1
- package/dist/secrets/index.js +2 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/secrets/routes.js +7 -8
- package/dist/secrets/schema.d.ts +51 -0
- package/dist/secrets/schema.js +3 -0
- package/dist/secrets/storage.d.ts +83 -1
- package/dist/secrets/storage.js +194 -6
- package/dist/secrets/usage.js +2 -4
- package/dist/secrets/vault-managed.d.ts +17 -0
- package/dist/secrets/vault-managed.js +19 -0
- package/dist/server/agent-engine-api-key-route.d.ts +2 -2
- package/dist/server/builder-signup-experiment.d.ts +4 -4
- package/dist/server/credential-provider.js +7 -7
- package/dist/server/first-touch-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/workspace-core/.agents/skills/external-agents/references/mcp-apps-embedding.md +11 -0
- package/docs/content/security.mdx +1 -1
- package/docs/content/template-dispatch-developers.mdx +7 -7
- package/docs/content/template-dispatch-reference.mdx +16 -10
- package/package.json +4 -4
- package/src/templates/workspace-core/.agents/skills/external-agents/references/mcp-apps-embedding.md +11 -0
|
@@ -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.
|
package/dist/secrets/storage.js
CHANGED
|
@@ -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
|
-
|
|
142
|
-
|
|
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;
|
package/dist/secrets/usage.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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;
|
|
@@ -66,9 +66,9 @@ export declare function createAgentEngineApiKeyHandler(): import("h3").EventHand
|
|
|
66
66
|
error: string;
|
|
67
67
|
code: ProviderKeyCheckCode;
|
|
68
68
|
} | {
|
|
69
|
-
errorCode?: undefined;
|
|
70
69
|
error?: 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;
|
|
@@ -27,14 +27,14 @@ async function canReadDesignatedVaultFallback(vaultOrgId, credentialKey) {
|
|
|
27
27
|
}
|
|
28
28
|
try {
|
|
29
29
|
const result = await getDbExec().execute({
|
|
30
|
+
// A grant names its key itself; legacy grants without `credential_key`
|
|
31
|
+
// match nothing until they are backfilled.
|
|
30
32
|
sql: `SELECT 1
|
|
31
|
-
FROM vault_grants
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
AND
|
|
35
|
-
AND
|
|
36
|
-
AND secrets.org_id = grants.org_id
|
|
37
|
-
AND secrets.credential_key = ?
|
|
33
|
+
FROM vault_grants
|
|
34
|
+
WHERE org_id = ?
|
|
35
|
+
AND app_id = ?
|
|
36
|
+
AND status = 'active'
|
|
37
|
+
AND credential_key = ?
|
|
38
38
|
LIMIT 1`,
|
|
39
39
|
args: [vaultOrgId, appId, credentialKey],
|
|
40
40
|
});
|
|
@@ -17,8 +17,8 @@ export type FirstTouchSyncResult = {
|
|
|
17
17
|
* browser's `an_ft` and `an_lt` again, so Safari keeps them their 30 days.
|
|
18
18
|
*/
|
|
19
19
|
export declare function createFirstTouchHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
20
|
-
error: string;
|
|
21
20
|
reason?: undefined;
|
|
21
|
+
error: string;
|
|
22
22
|
stored?: undefined;
|
|
23
23
|
firstTouch?: undefined;
|
|
24
24
|
} | {
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
* typed JSON-in / JSON-out).
|
|
18
18
|
*/
|
|
19
19
|
export declare function createTranscribeVoiceHandler(): import("h3").EventHandlerWithFetch<import("h3").EventHandlerRequest, Promise<{
|
|
20
|
-
error: string;
|
|
21
20
|
text?: undefined;
|
|
21
|
+
error: string;
|
|
22
22
|
} | {
|
|
23
23
|
error?: undefined;
|
|
24
24
|
text: string;
|
|
@@ -120,6 +120,17 @@ resource CSP should include the exact origins of apps granted through Dispatch,
|
|
|
120
120
|
not broad sources like `https:`. This lets Claude's transplant path fetch the
|
|
121
121
|
signed target app HTML while keeping the connector's resource surface narrow.
|
|
122
122
|
|
|
123
|
+
Side panel and app tools: every `embedApp()` resource sets
|
|
124
|
+
`_meta.ui.preferredDisplayMode: "fullscreen"`, so Claude opens the app in its
|
|
125
|
+
side panel beside the chat (other hosts ignore the field). The shell declares
|
|
126
|
+
`availableDisplayModes` and `tools` in `ui/initialize` and answers the host's
|
|
127
|
+
`tools/list` / `tools/call` on the open view: `read_view` returns the page's
|
|
128
|
+
path, title, selection and visible text, `navigate_view` moves the open app to
|
|
129
|
+
another same-origin path in place, and any page tools the app registered through
|
|
130
|
+
WebMCP are passed through. Prefer these over a new `open_app` call when the user
|
|
131
|
+
asks to change what the open panel shows, since each new UI tool call opens a
|
|
132
|
+
new view.
|
|
133
|
+
|
|
123
134
|
Host sizing rule: the MCP resource shell owns a bounded inline height and the
|
|
124
135
|
embedded route should scroll internally. `embedApp({ height })` defaults to a
|
|
125
136
|
`560px` shell, clamps to `320-900px`, and subtracts `44px` for the wrapper bar
|
|
@@ -322,7 +322,7 @@ pnpm action db-check-scoping --require-org # Also require org_id
|
|
|
322
322
|
| OAuth tokens (Google, GitHub) | `oauth_tokens` store via `saveOAuthTokens()` |
|
|
323
323
|
| Session tokens | Automatic (Better Auth handles this) |
|
|
324
324
|
|
|
325
|
-
Per-user/per-org credentials and the vault are encrypted at rest with AES-256-GCM. The Dispatch vault keeps no plaintext copy:
|
|
325
|
+
Per-user/per-org credentials and the vault are encrypted at rest with AES-256-GCM. The Dispatch vault keeps no plaintext copy: each key's name, label, and encrypted value live together in its `app_secrets` row, and the vault's own tables hold only grants, requests, and the audit log. For a per-user/per-org credential, the key precedence is `<APP_NAME>_SECRETS_ENCRYPTION_KEY` (app-scoped — useful in a multi-app workspace where apps shouldn't share one key) → `SECRETS_ENCRYPTION_KEY` → `BETTER_AUTH_SECRET`; production refuses to start without one of these set. The shared workspace vault (`app_secrets`, meant to decrypt the same in sibling apps) prefers `WORKSPACE_SECRETS_ENCRYPTION_KEY`, then the legacy combined `SECRETS_ENCRYPTION_KEY`, then material derived from `A2A_SECRET`, with `BETTER_AUTH_SECRET` and the app-scoped key retained as compatibility candidates. Successful fallback reads compare-and-swap re-encrypt the shared column under the preferred key. During a workspace-key rotation, set `WORKSPACE_SECRETS_ENCRYPTION_KEY_PREVIOUS` to the old value until reads have migrated existing rows. To encrypt any pre-existing plaintext credential rows in place, run `pnpm action db-migrate-encrypt-credentials` (idempotent, non-destructive).
|
|
326
326
|
|
|
327
327
|
Never store secrets in `settings`, `application_state`, source code, or action responses. Use the credential / vault APIs above — they handle both encryption and per-user scoping.
|
|
328
328
|
|