@agent-native/dispatch 0.18.0 → 0.19.0

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 (128) hide show
  1. package/dist/actions/create-workspace-resource.d.ts +13 -13
  2. package/dist/actions/delete-workspace-resource.d.ts +13 -13
  3. package/dist/actions/grant-vault-secrets-to-app.js +1 -1
  4. package/dist/actions/grant-vault-secrets-to-app.js.map +1 -1
  5. package/dist/actions/list-vault-audit.js +1 -1
  6. package/dist/actions/list-vault-audit.js.map +1 -1
  7. package/dist/actions/list-vault-secret-options.js +2 -9
  8. package/dist/actions/list-vault-secret-options.js.map +1 -1
  9. package/dist/actions/list-vault-secrets.js +1 -1
  10. package/dist/actions/list-vault-secrets.js.map +1 -1
  11. package/dist/actions/provider-api-register.d.ts +7 -7
  12. package/dist/actions/set-app-creation-settings.js +1 -1
  13. package/dist/actions/set-app-creation-settings.js.map +1 -1
  14. package/dist/actions/set-vault-access-settings.js +1 -1
  15. package/dist/actions/set-vault-access-settings.js.map +1 -1
  16. package/dist/actions/sync-vault-to-app.js +1 -1
  17. package/dist/actions/sync-vault-to-app.js.map +1 -1
  18. package/dist/actions/view-screen.js +4 -3
  19. package/dist/actions/view-screen.js.map +1 -1
  20. package/dist/components/admin-navigation.d.ts +15 -0
  21. package/dist/components/admin-navigation.d.ts.map +1 -0
  22. package/dist/components/admin-navigation.js +174 -0
  23. package/dist/components/admin-navigation.js.map +1 -0
  24. package/dist/components/automation-run-history-dialog.js +1 -1
  25. package/dist/components/automation-run-history-dialog.js.map +1 -1
  26. package/dist/components/index.d.ts +1 -0
  27. package/dist/components/index.d.ts.map +1 -1
  28. package/dist/components/index.js +1 -0
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/layout/Header.d.ts.map +1 -1
  31. package/dist/components/layout/Header.js +9 -0
  32. package/dist/components/layout/Header.js.map +1 -1
  33. package/dist/components/layout/Layout.d.ts +4 -1
  34. package/dist/components/layout/Layout.d.ts.map +1 -1
  35. package/dist/components/layout/Layout.js +14 -117
  36. package/dist/components/layout/Layout.js.map +1 -1
  37. package/dist/components/layout/workspace-apps-rail.d.ts +5 -0
  38. package/dist/components/layout/workspace-apps-rail.d.ts.map +1 -0
  39. package/dist/components/layout/workspace-apps-rail.js +76 -0
  40. package/dist/components/layout/workspace-apps-rail.js.map +1 -0
  41. package/dist/hooks/use-navigation-state.js +39 -16
  42. package/dist/hooks/use-navigation-state.js.map +1 -1
  43. package/dist/lib/automation-display.js +1 -1
  44. package/dist/lib/automation-display.js.map +1 -1
  45. package/dist/routes/index.d.ts.map +1 -1
  46. package/dist/routes/index.js +22 -0
  47. package/dist/routes/index.js.map +1 -1
  48. package/dist/routes/pages/admin._index.d.ts +5 -0
  49. package/dist/routes/pages/admin._index.d.ts.map +1 -0
  50. package/dist/routes/pages/admin._index.js +19 -0
  51. package/dist/routes/pages/admin._index.js.map +1 -0
  52. package/dist/routes/pages/admin.d.ts +5 -0
  53. package/dist/routes/pages/admin.d.ts.map +1 -0
  54. package/dist/routes/pages/admin.js +10 -0
  55. package/dist/routes/pages/admin.js.map +1 -0
  56. package/dist/routes/pages/approval.js +2 -2
  57. package/dist/routes/pages/approval.js.map +1 -1
  58. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  59. package/dist/routes/pages/apps.$appId.js +3 -2
  60. package/dist/routes/pages/apps.$appId.js.map +1 -1
  61. package/dist/routes/pages/integrations.d.ts.map +1 -1
  62. package/dist/routes/pages/integrations.js +2 -1
  63. package/dist/routes/pages/integrations.js.map +1 -1
  64. package/dist/routes/pages/operations.js +3 -3
  65. package/dist/routes/pages/operations.js.map +1 -1
  66. package/dist/routes/pages/settings.d.ts.map +1 -1
  67. package/dist/routes/pages/settings.js +1 -1
  68. package/dist/routes/pages/settings.js.map +1 -1
  69. package/dist/routes/pages/team.js +1 -1
  70. package/dist/routes/pages/team.js.map +1 -1
  71. package/dist/routes/pages/transactional-email.$appId.$id.d.ts.map +1 -1
  72. package/dist/routes/pages/transactional-email.$appId.$id.js +5 -2
  73. package/dist/routes/pages/transactional-email.$appId.$id.js.map +1 -1
  74. package/dist/routes/pages/transactional-email.d.ts.map +1 -1
  75. package/dist/routes/pages/transactional-email.js +6 -2
  76. package/dist/routes/pages/transactional-email.js.map +1 -1
  77. package/dist/routes/pages/vault.d.ts.map +1 -1
  78. package/dist/routes/pages/vault.js +20 -16
  79. package/dist/routes/pages/vault.js.map +1 -1
  80. package/dist/server/lib/app-creation-store.js +2 -2
  81. package/dist/server/lib/app-creation-store.js.map +1 -1
  82. package/dist/server/lib/thread-debug-store.d.ts.map +1 -1
  83. package/dist/server/lib/thread-debug-store.js +2 -1
  84. package/dist/server/lib/thread-debug-store.js.map +1 -1
  85. package/dist/server/lib/vault-store.d.ts +15 -0
  86. package/dist/server/lib/vault-store.d.ts.map +1 -1
  87. package/dist/server/lib/vault-store.js +110 -17
  88. package/dist/server/lib/vault-store.js.map +1 -1
  89. package/package.json +3 -3
  90. package/src/actions/grant-vault-secrets-to-app.ts +1 -1
  91. package/src/actions/list-vault-audit.ts +1 -1
  92. package/src/actions/list-vault-secret-options.ts +2 -9
  93. package/src/actions/list-vault-secrets.ts +1 -1
  94. package/src/actions/set-app-creation-settings.ts +1 -1
  95. package/src/actions/set-vault-access-settings.ts +1 -1
  96. package/src/actions/sync-vault-to-app.ts +1 -1
  97. package/src/actions/view-screen.spec.ts +1 -0
  98. package/src/actions/view-screen.ts +5 -3
  99. package/src/components/admin-navigation.tsx +278 -0
  100. package/src/components/automation-run-history-dialog.tsx +1 -1
  101. package/src/components/index.ts +1 -0
  102. package/src/components/layout/Header.tsx +8 -0
  103. package/src/components/layout/Layout.spec.tsx +85 -6
  104. package/src/components/layout/Layout.tsx +62 -206
  105. package/src/components/layout/workspace-apps-rail.tsx +154 -0
  106. package/src/hooks/use-navigation-state.spec.ts +11 -0
  107. package/src/hooks/use-navigation-state.ts +37 -16
  108. package/src/lib/automation-display.spec.ts +1 -1
  109. package/src/lib/automation-display.ts +1 -1
  110. package/src/routes/index.spec.ts +8 -0
  111. package/src/routes/index.ts +25 -0
  112. package/src/routes/pages/admin._index.tsx +39 -0
  113. package/src/routes/pages/admin.tsx +15 -0
  114. package/src/routes/pages/approval.tsx +2 -2
  115. package/src/routes/pages/apps.$appId.tsx +7 -2
  116. package/src/routes/pages/integrations.tsx +3 -2
  117. package/src/routes/pages/operations.tsx +3 -3
  118. package/src/routes/pages/settings.tsx +7 -3
  119. package/src/routes/pages/team.tsx +1 -1
  120. package/src/routes/pages/transactional-email.$appId.$id.tsx +9 -2
  121. package/src/routes/pages/transactional-email.tsx +6 -2
  122. package/src/routes/pages/vault.tsx +152 -89
  123. package/src/server/lib/app-creation-store.ts +2 -2
  124. package/src/server/lib/approval-fencing.spec.ts +69 -0
  125. package/src/server/lib/thread-debug-store.spec.ts +7 -3
  126. package/src/server/lib/thread-debug-store.ts +2 -1
  127. package/src/server/lib/vault-store.spec.ts +59 -0
  128. package/src/server/lib/vault-store.ts +123 -17
@@ -50,6 +50,43 @@ beforeEach(async () => {
50
50
  await runMigrations(dispatchMigrations, {
51
51
  table: "dispatch_migrations",
52
52
  })({});
53
+
54
+ const { getDbExec } = await import("@agent-native/core/db");
55
+ const exec = getDbExec();
56
+ await exec.execute({
57
+ sql: `CREATE TABLE IF NOT EXISTS org_members (
58
+ id TEXT PRIMARY KEY,
59
+ org_id TEXT NOT NULL,
60
+ email TEXT NOT NULL,
61
+ role TEXT NOT NULL,
62
+ joined_at INTEGER NOT NULL
63
+ )`,
64
+ args: [],
65
+ });
66
+ await exec.execute({
67
+ sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)",
68
+ args: ["approval-owner", orgId, ownerEmail, "owner", Date.now()],
69
+ });
70
+ await exec.execute({
71
+ sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)",
72
+ args: [
73
+ "approval-other-owner",
74
+ otherOrgId,
75
+ otherOwnerEmail,
76
+ "owner",
77
+ Date.now(),
78
+ ],
79
+ });
80
+ await exec.execute({
81
+ sql: "INSERT INTO org_members (id, org_id, email, role, joined_at) VALUES (?, ?, ?, ?, ?)",
82
+ args: [
83
+ "approval-owner-other-org",
84
+ otherOrgId,
85
+ ownerEmail,
86
+ "owner",
87
+ Date.now(),
88
+ ],
89
+ });
53
90
  });
54
91
 
55
92
  afterEach(async () => {
@@ -65,6 +102,38 @@ afterEach(async () => {
65
102
  });
66
103
 
67
104
  describe("dispatch approval request status fencing", () => {
105
+ it("does not approve a request through an owner-email match from another org", async () => {
106
+ const [{ runWithRequestContext }, { getDbExec }, vaultStore] =
107
+ await Promise.all([
108
+ import("@agent-native/core/server"),
109
+ import("@agent-native/core/db"),
110
+ import("./vault-store.js"),
111
+ ]);
112
+ const exec = getDbExec();
113
+
114
+ const created = await runWithRequestContext(
115
+ { userEmail: ownerEmail, orgId },
116
+ () =>
117
+ vaultStore.createRequest({
118
+ credentialKey: "CROSS_ORG_KEY",
119
+ appId: "test-app",
120
+ reason: "must stay in the request org",
121
+ }),
122
+ );
123
+
124
+ await expect(
125
+ runWithRequestContext({ userEmail: ownerEmail, orgId: otherOrgId }, () =>
126
+ vaultStore.approveRequest((created as any).id, "secret-value"),
127
+ ),
128
+ ).rejects.toThrow("Request not found");
129
+
130
+ const rows = await exec.execute({
131
+ sql: "SELECT status FROM vault_requests WHERE id = ?",
132
+ args: [(created as any).id],
133
+ });
134
+ expect(rows.rows[0]).toMatchObject({ status: "pending" });
135
+ });
136
+
68
137
  it("applies the change once when approveRequest is called twice on the same request", async () => {
69
138
  const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
70
139
  await Promise.all([
@@ -390,9 +390,13 @@ describe("thread-debug-store", () => {
390
390
  it("keeps explicit remote sources admin-only", async () => {
391
391
  vi.stubEnv("REMOTE_DATABASE_URL", "libsql://remote");
392
392
 
393
- await expect(listAgentRunFailures({ sourceId: "remote" })).rejects.toThrow(
394
- "Only Dispatch admins",
395
- );
393
+ await expect(
394
+ listAgentRunFailures({ sourceId: "remote" }),
395
+ ).rejects.toMatchObject({
396
+ statusCode: 403,
397
+ message:
398
+ "Only Dispatch admins can inspect thread databases from other apps.",
399
+ });
396
400
  expect(mocks.createDbExec).not.toHaveBeenCalled();
397
401
  });
398
402
 
@@ -3,6 +3,7 @@ import {
3
3
  type AgentFailureRegime,
4
4
  } from "@agent-native/core/agent/engine";
5
5
  import { createDbExec, getDbExec, type DbExec } from "@agent-native/core/db";
6
+ import { ForbiddenError } from "@agent-native/core/sharing";
6
7
 
7
8
  import { currentOrgId, currentOwnerEmail } from "./dispatch-store.js";
8
9
 
@@ -496,7 +497,7 @@ function assertSourceAccess(
496
497
  ) {
497
498
  if (source.kind === "current") return;
498
499
  if (!access.canInspectAll) {
499
- throw new Error(
500
+ throw new ForbiddenError(
500
501
  "Only Dispatch admins can inspect thread databases from other apps.",
501
502
  );
502
503
  }
@@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({
5
5
  currentOwnerEmail: vi.fn(),
6
6
  deleteAppSecret: vi.fn(),
7
7
  discoverAgents: vi.fn(),
8
+ getDbExec: vi.fn(),
8
9
  getDb: vi.fn(),
9
10
  getOrgSetting: vi.fn(),
10
11
  getUserSetting: vi.fn(),
@@ -21,6 +22,11 @@ vi.mock("@agent-native/core/secrets", () => ({
21
22
  writeAppSecret: mocks.writeAppSecret,
22
23
  }));
23
24
 
25
+ vi.mock("@agent-native/core/db", async (importOriginal) => ({
26
+ ...(await importOriginal<typeof import("@agent-native/core/db")>()),
27
+ getDbExec: mocks.getDbExec,
28
+ }));
29
+
24
30
  vi.mock("@agent-native/core/server/agent-discovery", () => ({
25
31
  discoverAgents: mocks.discoverAgents,
26
32
  }));
@@ -49,9 +55,12 @@ vi.mock("../../db/index.js", async (importOriginal) => {
49
55
  import { readAppSecret } from "@agent-native/core/secrets";
50
56
 
51
57
  import {
58
+ assertCanManageVault,
52
59
  cleanupSyncedCredentialKeysIfUnused,
53
60
  credentialStoreScopeForVaultCtx,
54
61
  isTrustedEnvVarSyncAgentUrl,
62
+ getGrant,
63
+ listGrants,
55
64
  resyncAllVaultSecretsToCredentialStore,
56
65
  syncGrantsToApp,
57
66
  syncSecretsToCredentialStore,
@@ -85,6 +94,56 @@ describe("credentialStoreScopeForVaultCtx", () => {
85
94
  });
86
95
  });
87
96
 
97
+ describe("vault authorization", () => {
98
+ it("rejects an organization member before reading shared vault values", async () => {
99
+ mocks.currentOrgId.mockReturnValue("org_123");
100
+ mocks.currentOwnerEmail.mockReturnValue("member@example.test");
101
+ mocks.getDbExec.mockReturnValue({
102
+ execute: vi.fn().mockResolvedValue({ rows: [{ role: "member" }] }),
103
+ });
104
+
105
+ await expect(assertCanManageVault()).rejects.toThrow(
106
+ "Only organization owners and admins can manage the workspace vault.",
107
+ );
108
+ });
109
+
110
+ it("rejects organization members before listing or reading grants", async () => {
111
+ mocks.currentOrgId.mockReturnValue("org_123");
112
+ mocks.currentOwnerEmail.mockReturnValue("member@example.test");
113
+ mocks.getDbExec.mockReturnValue({
114
+ execute: vi.fn().mockResolvedValue({ rows: [{ role: "member" }] }),
115
+ });
116
+
117
+ await expect(listGrants()).rejects.toThrow(
118
+ "Only organization owners and admins can manage the workspace vault.",
119
+ );
120
+ await expect(getGrant("grant-1")).rejects.toThrow(
121
+ "Only organization owners and admins can manage the workspace vault.",
122
+ );
123
+ expect(mocks.getDb).not.toHaveBeenCalled();
124
+ });
125
+
126
+ it.each(["owner", "admin"])(
127
+ "allows an organization %s to manage shared vault values",
128
+ async (role) => {
129
+ mocks.currentOrgId.mockReturnValue("org_123");
130
+ mocks.currentOwnerEmail.mockReturnValue(`${role}@example.test`);
131
+ mocks.getDbExec.mockReturnValue({
132
+ execute: vi.fn().mockResolvedValue({ rows: [{ role }] }),
133
+ });
134
+
135
+ await expect(assertCanManageVault()).resolves.toBeUndefined();
136
+ },
137
+ );
138
+
139
+ it("allows a solo workspace owner to manage their own vault", async () => {
140
+ mocks.currentOrgId.mockReturnValue(null);
141
+
142
+ await expect(assertCanManageVault()).resolves.toBeUndefined();
143
+ expect(mocks.getDbExec).not.toHaveBeenCalled();
144
+ });
145
+ });
146
+
88
147
  describe("isTrustedEnvVarSyncAgentUrl", () => {
89
148
  it("allows localhost development app URLs", () => {
90
149
  expect(isTrustedEnvVarSyncAgentUrl("http://localhost:9201")).toBe(true);
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import { getDbExec } from "@agent-native/core/db";
3
4
  import { and, desc, eq, isNull, or, sql } from "@agent-native/core/db/schema";
4
5
  import { ssrfSafeFetch } from "@agent-native/core/extensions/url-safety";
5
6
  import {
@@ -72,6 +73,15 @@ function ctxScope<T extends { ownerEmail: any; orgId: any }>(
72
73
  return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId));
73
74
  }
74
75
 
76
+ /** Scope an approval operation to the active tenant, never just the owner email. */
77
+ function tenantScope<T extends { ownerEmail: any; orgId: any }>(
78
+ table: T,
79
+ ctx: VaultCtx,
80
+ ) {
81
+ if (ctx.orgId) return eq(table.orgId, ctx.orgId);
82
+ return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId));
83
+ }
84
+
75
85
  /** Build a ctx that scopes to a specific row's owner/org (used when a
76
86
  * request approver acts on behalf of the original requester so the
77
87
  * created secret lands in the request's org). */
@@ -140,6 +150,45 @@ function scopedFilter<T extends { ownerEmail: any; orgId: any }>(table: T) {
140
150
  return ctxScope(table, requireVaultCtx());
141
151
  }
142
152
 
153
+ /**
154
+ * Shared vault values and grants are organization administration data. Keep
155
+ * the member path available for request creation and safe key metadata, but
156
+ * fail closed before a member can read or mutate a raw secret.
157
+ */
158
+ export async function assertCanManageVault(): Promise<void> {
159
+ const orgId = currentOrgId();
160
+ if (!orgId) return;
161
+
162
+ const email = currentOwnerEmail().trim().toLowerCase();
163
+ let role: unknown = null;
164
+ try {
165
+ const result = await getDbExec().execute({
166
+ sql: "SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1",
167
+ args: [orgId, email],
168
+ });
169
+ role = result.rows[0]?.role;
170
+ } catch {
171
+ // coercion-ok: unknown membership is treated as unauthorized below.
172
+ // A failed membership check must not become an authorization bypass.
173
+ }
174
+
175
+ if (role !== "owner" && role !== "admin") {
176
+ throw new Error(
177
+ "Only organization owners and admins can manage the workspace vault.",
178
+ );
179
+ }
180
+ }
181
+
182
+ export async function canManageVault(): Promise<boolean> {
183
+ try {
184
+ await assertCanManageVault();
185
+ return true;
186
+ } catch {
187
+ // coercion-ok: the UI must treat an authorization lookup failure as no access.
188
+ return false;
189
+ }
190
+ }
191
+
143
192
  function normalizeCredentialKey(value: string) {
144
193
  return value.trim();
145
194
  }
@@ -169,6 +218,7 @@ export async function getVaultAccessSettings(): Promise<VaultAccessSettings> {
169
218
  export async function setVaultAccessSettings(input: {
170
219
  mode: VaultAccessMode;
171
220
  }): Promise<VaultAccessSettings> {
221
+ await assertCanManageVault();
172
222
  const scope = vaultAccessScope();
173
223
  const next = { mode: parseVaultAccessMode(input.mode) };
174
224
  if (scope.scope === "org") {
@@ -215,6 +265,7 @@ export async function recordVaultAudit(input: {
215
265
  }
216
266
 
217
267
  export async function listVaultAudit(limit = 50) {
268
+ await assertCanManageVault();
218
269
  const db = getDb();
219
270
  return db
220
271
  .select()
@@ -227,6 +278,7 @@ export async function listVaultAudit(limit = 50) {
227
278
  // ─── Secrets ──────────────────────────────────────────────────────
228
279
 
229
280
  export async function listSecrets() {
281
+ await assertCanManageVault();
230
282
  const db = getDb();
231
283
  return db
232
284
  .select()
@@ -235,7 +287,24 @@ export async function listSecrets() {
235
287
  .orderBy(desc(schema.vaultSecrets.updatedAt));
236
288
  }
237
289
 
290
+ /** Safe metadata for app creation and integration readiness checks. */
291
+ export async function listSecretOptions() {
292
+ const db = getDb();
293
+ return db
294
+ .select({
295
+ id: schema.vaultSecrets.id,
296
+ name: schema.vaultSecrets.name,
297
+ credentialKey: schema.vaultSecrets.credentialKey,
298
+ provider: schema.vaultSecrets.provider,
299
+ description: schema.vaultSecrets.description,
300
+ })
301
+ .from(schema.vaultSecrets)
302
+ .where(scopedFilter(schema.vaultSecrets))
303
+ .orderBy(desc(schema.vaultSecrets.updatedAt));
304
+ }
305
+
238
306
  export async function getSecret(secretId: string, ctx: VaultCtx) {
307
+ await assertCanManageVault();
239
308
  const db = getDb();
240
309
  const [row] = await db
241
310
  .select()
@@ -260,6 +329,7 @@ export async function createSecret(
260
329
  },
261
330
  ctx: VaultCtx = requireVaultCtx(),
262
331
  ) {
332
+ await assertCanManageVault();
263
333
  const db = getDb();
264
334
  const timestamp = now();
265
335
  const credentialKey = normalizeCredentialKey(input.credentialKey);
@@ -372,6 +442,7 @@ export async function updateSecret(
372
442
  },
373
443
  ctx: VaultCtx = requireVaultCtx(),
374
444
  ) {
445
+ await assertCanManageVault();
375
446
  const db = getDb();
376
447
  const existing = await getSecret(secretId, ctx);
377
448
  if (!existing) throw new Error("Secret not found");
@@ -478,6 +549,7 @@ export async function deleteSecret(
478
549
  secretId: string,
479
550
  ctx: VaultCtx = requireVaultCtx(),
480
551
  ) {
552
+ await assertCanManageVault();
481
553
  const db = getDb();
482
554
  const existing = await getSecret(secretId, ctx);
483
555
  if (!existing) throw new Error("Secret not found");
@@ -524,6 +596,7 @@ export async function listGrants(filter?: {
524
596
  secretId?: string;
525
597
  appId?: string;
526
598
  }) {
599
+ await assertCanManageVault();
527
600
  const db = getDb();
528
601
  const conditions = [scopedFilter(schema.vaultGrants)];
529
602
  if (filter?.secretId) {
@@ -543,6 +616,7 @@ export async function getGrant(
543
616
  grantId: string,
544
617
  ctx: VaultCtx = requireVaultCtx(),
545
618
  ) {
619
+ await assertCanManageVault();
546
620
  const db = getDb();
547
621
  const [row] = await db
548
622
  .select()
@@ -562,6 +636,7 @@ export async function createGrant(
562
636
  appId: string,
563
637
  ctx: VaultCtx = requireVaultCtx(),
564
638
  ) {
639
+ await assertCanManageVault();
565
640
  const db = getDb();
566
641
  const secret = await getSecret(secretId, ctx);
567
642
  if (!secret) throw new Error("Secret not found");
@@ -657,6 +732,7 @@ export async function grantSecretsToApp(
657
732
  appId: string,
658
733
  ctx: VaultCtx = requireVaultCtx(),
659
734
  ) {
735
+ await assertCanManageVault();
660
736
  const access = await getVaultAccessSettings();
661
737
  const uniqueSecretIds = Array.from(new Set(secretIds));
662
738
  if (access.mode === "all-apps") {
@@ -695,6 +771,7 @@ export async function revokeGrant(
695
771
  grantId: string,
696
772
  ctx: VaultCtx = requireVaultCtx(),
697
773
  ) {
774
+ await assertCanManageVault();
698
775
  const db = getDb();
699
776
  const grant = await getGrant(grantId, ctx);
700
777
  if (!grant) throw new Error("Grant not found");
@@ -878,6 +955,7 @@ export async function syncGrantsToApp(
878
955
  appId: string,
879
956
  ctx: VaultCtx = requireVaultCtx(),
880
957
  ) {
958
+ await assertCanManageVault();
881
959
  const db = getDb();
882
960
  const access = await getVaultAccessSettings();
883
961
  const agents = await discoverAgents("dispatch");
@@ -1023,7 +1101,13 @@ export async function syncGrantsToApp(
1023
1101
 
1024
1102
  export async function listRequests(filter?: { status?: string }) {
1025
1103
  const db = getDb();
1026
- const conditions = [scopedFilter(schema.vaultRequests)];
1104
+ const ctx = requireVaultCtx();
1105
+ const isAdmin = await canManageVault();
1106
+ const conditions = [
1107
+ isAdmin
1108
+ ? ctxScope(schema.vaultRequests, ctx)
1109
+ : eq(schema.vaultRequests.ownerEmail, ctx.ownerEmail),
1110
+ ];
1027
1111
  if (filter?.status) {
1028
1112
  conditions.push(eq(schema.vaultRequests.status, filter.status) as any);
1029
1113
  }
@@ -1052,6 +1136,21 @@ export async function getRequest(
1052
1136
  return row ?? null;
1053
1137
  }
1054
1138
 
1139
+ async function getRequestForTenant(requestId: string, ctx: VaultCtx) {
1140
+ const db = getDb();
1141
+ const [row] = await db
1142
+ .select()
1143
+ .from(schema.vaultRequests)
1144
+ .where(
1145
+ and(
1146
+ eq(schema.vaultRequests.id, requestId),
1147
+ tenantScope(schema.vaultRequests, ctx),
1148
+ ),
1149
+ )
1150
+ .limit(1);
1151
+ return row ?? null;
1152
+ }
1153
+
1055
1154
  export async function createRequest(input: {
1056
1155
  credentialKey: string;
1057
1156
  appId: string;
@@ -1095,8 +1194,9 @@ export async function approveRequest(
1095
1194
  secretName?: string,
1096
1195
  ctx: VaultCtx = requireVaultCtx(),
1097
1196
  ) {
1197
+ await assertCanManageVault();
1098
1198
  const db = getDb();
1099
- const request = await getRequest(requestId, ctx);
1199
+ const request = await getRequestForTenant(requestId, ctx);
1100
1200
  if (!request) throw new Error("Request not found");
1101
1201
 
1102
1202
  const timestamp = now();
@@ -1119,7 +1219,7 @@ export async function approveRequest(
1119
1219
  .where(
1120
1220
  and(
1121
1221
  eq(schema.vaultRequests.id, requestId),
1122
- ctxScope(schema.vaultRequests, ctx),
1222
+ tenantScope(schema.vaultRequests, ctx),
1123
1223
  or(
1124
1224
  eq(schema.vaultRequests.status, "pending"),
1125
1225
  and(
@@ -1132,7 +1232,7 @@ export async function approveRequest(
1132
1232
  .returning();
1133
1233
 
1134
1234
  if (claimed.length === 0) {
1135
- const current = await getRequest(requestId, ctx);
1235
+ const current = await getRequestForTenant(requestId, ctx);
1136
1236
  if (current?.status === "applying") {
1137
1237
  throw new Error("Vault request is already being applied");
1138
1238
  }
@@ -1180,7 +1280,7 @@ export async function approveRequest(
1180
1280
  .where(
1181
1281
  and(
1182
1282
  eq(schema.vaultRequests.id, requestId),
1183
- ctxScope(schema.vaultRequests, ctx),
1283
+ tenantScope(schema.vaultRequests, ctx),
1184
1284
  eq(schema.vaultRequests.status, "applying"),
1185
1285
  ),
1186
1286
  );
@@ -1198,7 +1298,7 @@ export async function approveRequest(
1198
1298
  .where(
1199
1299
  and(
1200
1300
  eq(schema.vaultRequests.id, requestId),
1201
- ctxScope(schema.vaultRequests, ctx),
1301
+ tenantScope(schema.vaultRequests, ctx),
1202
1302
  eq(schema.vaultRequests.status, "applying"),
1203
1303
  ),
1204
1304
  );
@@ -1210,7 +1310,7 @@ export async function approveRequest(
1210
1310
  metadata: { requestId, reviewer },
1211
1311
  });
1212
1312
 
1213
- return getRequest(requestId, ctx);
1313
+ return getRequestForTenant(requestId, ctx);
1214
1314
  }
1215
1315
 
1216
1316
  export async function denyRequest(
@@ -1218,8 +1318,9 @@ export async function denyRequest(
1218
1318
  reason?: string | null,
1219
1319
  ctx: VaultCtx = requireVaultCtx(),
1220
1320
  ) {
1321
+ await assertCanManageVault();
1221
1322
  const db = getDb();
1222
- const request = await getRequest(requestId, ctx);
1323
+ const request = await getRequestForTenant(requestId, ctx);
1223
1324
  if (!request) throw new Error("Request not found");
1224
1325
 
1225
1326
  const timestamp = now();
@@ -1236,14 +1337,14 @@ export async function denyRequest(
1236
1337
  .where(
1237
1338
  and(
1238
1339
  eq(schema.vaultRequests.id, requestId),
1239
- ctxScope(schema.vaultRequests, ctx),
1340
+ tenantScope(schema.vaultRequests, ctx),
1240
1341
  eq(schema.vaultRequests.status, "pending"),
1241
1342
  ),
1242
1343
  )
1243
1344
  .returning();
1244
1345
 
1245
1346
  if (claimed.length === 0) {
1246
- return getRequest(requestId, ctx);
1347
+ return getRequestForTenant(requestId, ctx);
1247
1348
  }
1248
1349
 
1249
1350
  const claimedRequest = claimed[0];
@@ -1255,7 +1356,7 @@ export async function denyRequest(
1255
1356
  metadata: { requestId, reviewer, reason },
1256
1357
  });
1257
1358
 
1258
- return getRequest(requestId, ctx);
1359
+ return getRequestForTenant(requestId, ctx);
1259
1360
  }
1260
1361
 
1261
1362
  // ─── Integrations Catalog ────────────────────────────────────────
@@ -1282,8 +1383,8 @@ export interface AppIntegrations {
1282
1383
  export async function listIntegrationsCatalog(): Promise<AppIntegrations[]> {
1283
1384
  const access = await getVaultAccessSettings();
1284
1385
  const agents = await discoverAgents("dispatch");
1285
- const grants = await listGrants();
1286
- const secrets = await listSecrets();
1386
+ const grants = (await canManageVault()) ? await listGrants() : [];
1387
+ const secrets = await listSecretOptions();
1287
1388
 
1288
1389
  const secretByKey = new Map(secrets.map((s) => [s.credentialKey, s]));
1289
1390
 
@@ -1366,9 +1467,10 @@ export async function listIntegrationsCatalog(): Promise<AppIntegrations[]> {
1366
1467
  // ─── Vault Overview (for dashboard) ──────────────────────────────
1367
1468
 
1368
1469
  export async function listVaultOverview() {
1470
+ const isAdmin = await canManageVault();
1369
1471
  const [secrets, grants, requests, access] = await Promise.all([
1370
- listSecrets(),
1371
- listGrants(),
1472
+ listSecretOptions(),
1473
+ isAdmin ? listGrants() : Promise.resolve([]),
1372
1474
  listRequests(),
1373
1475
  getVaultAccessSettings(),
1374
1476
  ]);
@@ -1378,8 +1480,12 @@ export async function listVaultOverview() {
1378
1480
  accessMode: access.mode,
1379
1481
  secretCount: secrets.length,
1380
1482
  activeGrantCount:
1381
- access.mode === "all-apps" ? secrets.length : manualGrantCount,
1382
- manualGrantCount,
1483
+ access.mode === "all-apps"
1484
+ ? secrets.length
1485
+ : isAdmin
1486
+ ? manualGrantCount
1487
+ : 0,
1488
+ manualGrantCount: isAdmin ? manualGrantCount : 0,
1383
1489
  pendingRequestCount: requests.filter((r) => r.status === "pending").length,
1384
1490
  };
1385
1491
  }