@agent-native/dispatch 0.14.2 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +114 -122
  2. package/dist/actions/approve-dispatch-change.d.ts +10 -10
  3. package/dist/actions/list_apps.d.ts +1 -0
  4. package/dist/actions/list_apps.js +16 -8
  5. package/dist/actions/list_apps.js.map +1 -1
  6. package/dist/actions/provider-api-register.js +64 -2
  7. package/dist/actions/provider-api-register.js.map +1 -1
  8. package/dist/actions/reject-dispatch-change.d.ts +10 -10
  9. package/dist/actions/set-mcp-app-access.js +1 -4
  10. package/dist/actions/set-mcp-app-access.js.map +1 -1
  11. package/dist/components/action-query-error.d.ts +8 -0
  12. package/dist/components/action-query-error.d.ts.map +1 -0
  13. package/dist/components/action-query-error.js +10 -0
  14. package/dist/components/action-query-error.js.map +1 -0
  15. package/dist/components/app-keys-popover.d.ts +4 -0
  16. package/dist/components/app-keys-popover.d.ts.map +1 -1
  17. package/dist/components/app-keys-popover.js +26 -14
  18. package/dist/components/app-keys-popover.js.map +1 -1
  19. package/dist/components/dispatch-control-plane.d.ts.map +1 -1
  20. package/dist/components/dispatch-control-plane.js +31 -27
  21. package/dist/components/dispatch-control-plane.js.map +1 -1
  22. package/dist/components/index.d.ts +1 -0
  23. package/dist/components/index.d.ts.map +1 -1
  24. package/dist/components/index.js +1 -0
  25. package/dist/components/index.js.map +1 -1
  26. package/dist/components/layout/Layout.d.ts +1 -0
  27. package/dist/components/layout/Layout.d.ts.map +1 -1
  28. package/dist/components/layout/Layout.js +19 -21
  29. package/dist/components/layout/Layout.js.map +1 -1
  30. package/dist/components/ui/sheet.js +1 -1
  31. package/dist/components/ui/sheet.js.map +1 -1
  32. package/dist/components/ui/toast.js +1 -1
  33. package/dist/components/ui/toast.js.map +1 -1
  34. package/dist/components/workspace-app-card.d.ts.map +1 -1
  35. package/dist/components/workspace-app-card.js +8 -5
  36. package/dist/components/workspace-app-card.js.map +1 -1
  37. package/dist/components/workspace-resource-effective-stack.d.ts.map +1 -1
  38. package/dist/components/workspace-resource-effective-stack.js +6 -1
  39. package/dist/components/workspace-resource-effective-stack.js.map +1 -1
  40. package/dist/components/workspace-resource-impact-preview.d.ts.map +1 -1
  41. package/dist/components/workspace-resource-impact-preview.js +6 -1
  42. package/dist/components/workspace-resource-impact-preview.js.map +1 -1
  43. package/dist/routes/pages/$appId.d.ts.map +1 -1
  44. package/dist/routes/pages/$appId.js +6 -1
  45. package/dist/routes/pages/$appId.js.map +1 -1
  46. package/dist/routes/pages/agents.d.ts.map +1 -1
  47. package/dist/routes/pages/agents.js +5 -8
  48. package/dist/routes/pages/agents.js.map +1 -1
  49. package/dist/routes/pages/approval.d.ts.map +1 -1
  50. package/dist/routes/pages/approval.js +6 -1
  51. package/dist/routes/pages/approval.js.map +1 -1
  52. package/dist/routes/pages/approvals.d.ts.map +1 -1
  53. package/dist/routes/pages/approvals.js +8 -5
  54. package/dist/routes/pages/approvals.js.map +1 -1
  55. package/dist/routes/pages/apps.$appId.d.ts.map +1 -1
  56. package/dist/routes/pages/apps.$appId.js +4 -2
  57. package/dist/routes/pages/apps.$appId.js.map +1 -1
  58. package/dist/routes/pages/apps.d.ts.map +1 -1
  59. package/dist/routes/pages/apps.js +9 -3
  60. package/dist/routes/pages/apps.js.map +1 -1
  61. package/dist/routes/pages/audit.d.ts.map +1 -1
  62. package/dist/routes/pages/audit.js +4 -2
  63. package/dist/routes/pages/audit.js.map +1 -1
  64. package/dist/routes/pages/chat.d.ts.map +1 -1
  65. package/dist/routes/pages/chat.js +16 -2
  66. package/dist/routes/pages/chat.js.map +1 -1
  67. package/dist/routes/pages/destinations.d.ts.map +1 -1
  68. package/dist/routes/pages/destinations.js +5 -3
  69. package/dist/routes/pages/destinations.js.map +1 -1
  70. package/dist/routes/pages/identities.d.ts.map +1 -1
  71. package/dist/routes/pages/identities.js +5 -3
  72. package/dist/routes/pages/identities.js.map +1 -1
  73. package/dist/routes/pages/integrations.d.ts.map +1 -1
  74. package/dist/routes/pages/integrations.js +9 -3
  75. package/dist/routes/pages/integrations.js.map +1 -1
  76. package/dist/routes/pages/thread-debug.d.ts.map +1 -1
  77. package/dist/routes/pages/thread-debug.js +7 -6
  78. package/dist/routes/pages/thread-debug.js.map +1 -1
  79. package/dist/routes/pages/vault.d.ts.map +1 -1
  80. package/dist/routes/pages/vault.js +23 -7
  81. package/dist/routes/pages/vault.js.map +1 -1
  82. package/dist/routes/pages/workspace.d.ts.map +1 -1
  83. package/dist/routes/pages/workspace.js +9 -3
  84. package/dist/routes/pages/workspace.js.map +1 -1
  85. package/dist/server/lib/dispatch-store.d.ts +24 -21
  86. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  87. package/dist/server/lib/dispatch-store.js +59 -23
  88. package/dist/server/lib/dispatch-store.js.map +1 -1
  89. package/dist/server/lib/mcp-access-store.d.ts.map +1 -1
  90. package/dist/server/lib/mcp-access-store.js +30 -2
  91. package/dist/server/lib/mcp-access-store.js.map +1 -1
  92. package/dist/server/lib/vault-store.d.ts.map +1 -1
  93. package/dist/server/lib/vault-store.js +102 -37
  94. package/dist/server/lib/vault-store.js.map +1 -1
  95. package/dist/server/plugins/agent-chat.d.ts +1 -1
  96. package/package.json +2 -3
  97. package/src/actions/list_apps.spec.ts +52 -0
  98. package/src/actions/list_apps.ts +16 -8
  99. package/src/actions/provider-api-register.spec.ts +193 -0
  100. package/src/actions/provider-api-register.ts +66 -1
  101. package/src/actions/set-mcp-app-access.spec.ts +73 -0
  102. package/src/actions/set-mcp-app-access.ts +1 -5
  103. package/src/components/action-query-error.spec.tsx +56 -0
  104. package/src/components/action-query-error.tsx +32 -0
  105. package/src/components/app-keys-popover.spec.tsx +85 -0
  106. package/src/components/app-keys-popover.tsx +76 -50
  107. package/src/components/dispatch-control-plane.spec.tsx +106 -0
  108. package/src/components/dispatch-control-plane.tsx +71 -115
  109. package/src/components/index.ts +1 -0
  110. package/src/components/layout/Layout.spec.tsx +42 -2
  111. package/src/components/layout/Layout.tsx +22 -27
  112. package/src/components/ui/sheet.tsx +1 -1
  113. package/src/components/ui/toast.tsx +1 -1
  114. package/src/components/workspace-app-card.spec.tsx +80 -0
  115. package/src/components/workspace-app-card.tsx +36 -25
  116. package/src/components/workspace-resource-effective-stack.tsx +13 -1
  117. package/src/components/workspace-resource-impact-preview.tsx +12 -1
  118. package/src/routes/pages/$appId.tsx +17 -1
  119. package/src/routes/pages/agents.tsx +24 -11
  120. package/src/routes/pages/approval.tsx +16 -4
  121. package/src/routes/pages/approvals.tsx +108 -89
  122. package/src/routes/pages/apps.$appId.tsx +9 -2
  123. package/src/routes/pages/apps.tsx +20 -7
  124. package/src/routes/pages/audit.tsx +29 -20
  125. package/src/routes/pages/chat.spec.tsx +94 -0
  126. package/src/routes/pages/chat.tsx +30 -10
  127. package/src/routes/pages/destinations.tsx +75 -62
  128. package/src/routes/pages/identities.tsx +74 -63
  129. package/src/routes/pages/integrations.tsx +17 -10
  130. package/src/routes/pages/thread-debug.tsx +18 -12
  131. package/src/routes/pages/vault.tsx +59 -26
  132. package/src/routes/pages/workspace.tsx +69 -55
  133. package/src/server/lib/approval-fencing.spec.ts +420 -0
  134. package/src/server/lib/dispatch-store.ts +94 -21
  135. package/src/server/lib/mcp-access-store.spec.ts +116 -4
  136. package/src/server/lib/mcp-access-store.ts +37 -2
  137. package/src/server/lib/mcp-gateway.spec.ts +79 -3
  138. package/src/server/lib/vault-store.ts +151 -45
@@ -12,17 +12,25 @@ export default defineAction({
12
12
  parallelSafe: true,
13
13
  run: async () => {
14
14
  const apps = await listGrantedDispatchMcpApps();
15
+ const appSummaries = apps.map((app) => ({
16
+ id: app.id,
17
+ name: app.name,
18
+ description: app.description,
19
+ url: app.url,
20
+ running: true,
21
+ source: "dispatch-mcp-grant",
22
+ }));
15
23
  return {
16
24
  workspace: true,
17
25
  gateway: "dispatch",
18
- apps: apps.map((app) => ({
19
- id: app.id,
20
- name: app.name,
21
- description: app.description,
22
- url: app.url,
23
- running: true,
24
- source: "dispatch-mcp-grant",
25
- })),
26
+ apps: appSummaries,
27
+ // MCP model-visible results are deliberately text-only unless a tool is
28
+ // app-only or opens an MCP App. Supplying a compact, valid JSON message
29
+ // keeps large app catalogs machine-readable instead of letting the
30
+ // generic text limiter cut the full result into invalid JSON.
31
+ message: JSON.stringify({
32
+ apps: appSummaries.map(({ id, name }) => ({ id, name })),
33
+ }),
26
34
  };
27
35
  },
28
36
  });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Tests for the org-scope authorization added to provider-api-register.
3
+ *
4
+ * Plan 014 (advisor-plans/014-custom-registry-scope-authorization.md): this
5
+ * action previously let ANY authenticated org member upsert/delete an
6
+ * ORG-scoped custom API provider with no owner/admin check. These tests
7
+ * assert that org-scope upsert/delete now require the caller to be an org
8
+ * owner or admin, while user-scope calls and org-scope reads remain
9
+ * unaffected.
10
+ */
11
+
12
+ import { beforeEach, describe, expect, it, vi } from "vitest";
13
+
14
+ const mocks = vi.hoisted(() => ({
15
+ getCredentialContext: vi.fn(),
16
+ execute: vi.fn(),
17
+ upsertCustomProvider: vi.fn(),
18
+ deleteCustomProvider: vi.fn(),
19
+ listCustomProviders: vi.fn(),
20
+ getCustomProvider: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("@agent-native/core/server", () => ({
24
+ getCredentialContext: mocks.getCredentialContext,
25
+ }));
26
+
27
+ vi.mock("@agent-native/core/db", () => ({
28
+ getDbExec: () => ({ execute: mocks.execute }),
29
+ }));
30
+
31
+ // Keep the real `assertCanMutateCustomProviderScope` (it's the guard under
32
+ // test's downstream enforcement point) and only mock the DB-backed CRUD
33
+ // functions, mirroring the `importOriginal` partial-mock pattern used in
34
+ // packages/dispatch/src/server/lib/vault-store.spec.ts.
35
+ vi.mock("@agent-native/core/provider-api", async (importOriginal) => {
36
+ const actual =
37
+ await importOriginal<typeof import("@agent-native/core/provider-api")>();
38
+ return {
39
+ ...actual,
40
+ upsertCustomProvider: mocks.upsertCustomProvider,
41
+ deleteCustomProvider: mocks.deleteCustomProvider,
42
+ listCustomProviders: mocks.listCustomProviders,
43
+ getCustomProvider: mocks.getCustomProvider,
44
+ };
45
+ });
46
+
47
+ const action = (await import("./provider-api-register.js")).default;
48
+
49
+ function mockOrgRole(role: string | null): void {
50
+ mocks.execute.mockResolvedValue({ rows: role ? [{ role }] : [] });
51
+ }
52
+
53
+ const baseUpsertInput = {
54
+ operation: "upsert" as const,
55
+ id: "my-api",
56
+ label: "My API",
57
+ baseUrl: "https://api.example.com",
58
+ auth: { type: "none" as const },
59
+ };
60
+
61
+ describe("provider-api-register org-scope authorization", () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ mocks.getCredentialContext.mockReturnValue({
65
+ userEmail: "member@example.com",
66
+ orgId: "org-a",
67
+ });
68
+ mocks.upsertCustomProvider.mockResolvedValue("my-api");
69
+ mocks.deleteCustomProvider.mockResolvedValue(true);
70
+ });
71
+
72
+ it("rejects an org-scope upsert from a plain member", async () => {
73
+ mockOrgRole("member");
74
+
75
+ await expect(
76
+ action.run({ ...baseUpsertInput, scope: "org" }),
77
+ ).rejects.toThrow(/owners and admins/i);
78
+ expect(mocks.upsertCustomProvider).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("rejects an org-scope delete from a plain member", async () => {
82
+ mockOrgRole("member");
83
+
84
+ await expect(
85
+ action.run({ operation: "delete", id: "my-api", scope: "org" }),
86
+ ).rejects.toThrow(/owners and admins/i);
87
+ expect(mocks.deleteCustomProvider).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("rejects an org-scope delete when the caller has no membership row", async () => {
91
+ mockOrgRole(null);
92
+
93
+ await expect(
94
+ action.run({ operation: "delete", id: "my-api", scope: "org" }),
95
+ ).rejects.toThrow(/owners and admins/i);
96
+ expect(mocks.deleteCustomProvider).not.toHaveBeenCalled();
97
+ });
98
+
99
+ it("allows an org-scope upsert from an owner and passes orgRole through", async () => {
100
+ mockOrgRole("owner");
101
+
102
+ await action.run({ ...baseUpsertInput, scope: "org" });
103
+
104
+ expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
105
+ expect.objectContaining({
106
+ scope: "org",
107
+ scopeId: "org-a",
108
+ orgRole: "owner",
109
+ }),
110
+ );
111
+ });
112
+
113
+ it("allows an org-scope delete from an admin and passes orgRole through", async () => {
114
+ mockOrgRole("admin");
115
+
116
+ await action.run({ operation: "delete", id: "my-api", scope: "org" });
117
+
118
+ expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
119
+ "org",
120
+ "org-a",
121
+ "my-api",
122
+ "admin",
123
+ );
124
+ });
125
+
126
+ it("does not require an org role for user-scope upsert/delete", async () => {
127
+ await action.run({ ...baseUpsertInput, scope: "user" });
128
+ await action.run({ operation: "delete", id: "my-api", scope: "user" });
129
+
130
+ expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
131
+ expect.objectContaining({
132
+ scope: "user",
133
+ scopeId: "member@example.com",
134
+ orgRole: null,
135
+ }),
136
+ );
137
+ expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
138
+ "user",
139
+ "member@example.com",
140
+ "my-api",
141
+ null,
142
+ );
143
+ // No membership lookup should even happen for user-scope calls.
144
+ expect(mocks.execute).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("allows an org-scope upsert/delete when the caller has no active org", async () => {
148
+ // No org at all — `resolveOrgId` never wired, or a genuinely org-less
149
+ // solo user. `scopeId` collapses to the caller's own email (same as
150
+ // scope: "user"), so there's no other org member to protect against;
151
+ // this must not hard-reject the action's own default scope.
152
+ mocks.getCredentialContext.mockReturnValue({
153
+ userEmail: "solo@example.com",
154
+ orgId: null,
155
+ });
156
+
157
+ await action.run({ ...baseUpsertInput, scope: "org" });
158
+ await action.run({ operation: "delete", id: "my-api", scope: "org" });
159
+
160
+ expect(mocks.upsertCustomProvider).toHaveBeenCalledWith(
161
+ expect.objectContaining({
162
+ scope: "org",
163
+ scopeId: "solo@example.com",
164
+ orgRole: "owner",
165
+ }),
166
+ );
167
+ expect(mocks.deleteCustomProvider).toHaveBeenCalledWith(
168
+ "org",
169
+ "solo@example.com",
170
+ "my-api",
171
+ "owner",
172
+ );
173
+ // No org, so no membership lookup should happen either.
174
+ expect(mocks.execute).not.toHaveBeenCalled();
175
+ });
176
+
177
+ it("does not gate org-scope list/get reads on org role", async () => {
178
+ mocks.listCustomProviders.mockResolvedValue([]);
179
+ mocks.getCustomProvider.mockResolvedValue(null);
180
+
181
+ await action.run({ operation: "list", scope: "org" });
182
+ await action.run({ operation: "get", id: "my-api", scope: "org" });
183
+
184
+ expect(mocks.listCustomProviders).toHaveBeenCalledWith("org", "org-a");
185
+ expect(mocks.getCustomProvider).toHaveBeenCalledWith(
186
+ "org",
187
+ "org-a",
188
+ "my-api",
189
+ );
190
+ // Reads are intentionally out of scope for the role gate (plan 014).
191
+ expect(mocks.execute).not.toHaveBeenCalled();
192
+ });
193
+ });
@@ -1,13 +1,50 @@
1
1
  import { defineAction } from "@agent-native/core";
2
+ import { getDbExec } from "@agent-native/core/db";
2
3
  import {
3
4
  upsertCustomProvider,
4
5
  deleteCustomProvider,
5
6
  listCustomProviders,
6
7
  getCustomProvider,
8
+ assertCanMutateCustomProviderScope,
7
9
  } from "@agent-native/core/provider-api";
8
10
  import { getCredentialContext } from "@agent-native/core/server";
9
11
  import { z } from "zod";
10
12
 
13
+ /**
14
+ * Resolve the caller's role in a specific org, straight from `org_members`.
15
+ *
16
+ * `getCredentialContext()` (from `@agent-native/core/server`) only exposes
17
+ * `{ userEmail, orgId }` — no role. `getOrgContext()` (from
18
+ * `@agent-native/core/org`) resolves role but requires an `H3Event`, which
19
+ * `defineAction` handlers are not given. This mirrors the established
20
+ * no-event role-lookup idiom already used for org-admin gating inside
21
+ * agent-callable/background code (see `isCurrentUserOrgAdmin` in
22
+ * `packages/core/src/jobs/tools.ts`, `getViewerOrgRole` in
23
+ * `packages/dispatch/src/server/lib/usage-metrics-store.ts`, and the same
24
+ * query in `packages/core/src/mcp/actions/service-token-access.ts`) — same
25
+ * SQL, same fail-closed-to-null-on-error semantics. Returns null (never an
26
+ * org role) on any lookup error or when the caller has no membership row in
27
+ * this org.
28
+ */
29
+ async function resolveCallerOrgRole(
30
+ orgId: string | null,
31
+ email: string,
32
+ ): Promise<string | null> {
33
+ if (!orgId) return null;
34
+ try {
35
+ const client = getDbExec();
36
+ const { rows } = await client.execute({
37
+ sql: `SELECT role FROM org_members WHERE org_id = ? AND LOWER(email) = ? LIMIT 1`,
38
+ args: [orgId, email.toLowerCase()],
39
+ });
40
+ if (rows.length === 0) return null;
41
+ const role = (rows[0] as { role?: unknown }).role;
42
+ return typeof role === "string" && role ? role : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
11
48
  const AuthSchema = z.discriminatedUnion("type", [
12
49
  z.object({
13
50
  type: z.literal("none"),
@@ -138,6 +175,33 @@ After registration the provider appears in provider-api-catalog and can be used
138
175
  const scopeId =
139
176
  scope === "org" ? (ctx.orgId ?? ctx.userEmail) : ctx.userEmail;
140
177
 
178
+ // Only upsert/delete mutate state; list/get remain readable by any org
179
+ // member (scoping org-scoped reads is left as a follow-up — see plan
180
+ // 014). Resolve the caller's role in the *target* org (`scopeId`, which
181
+ // for scope === "org" is exactly `ctx.orgId`) and enforce owner/admin
182
+ // before allowing an org-scoped write. `assertCanMutateCustomProviderScope`
183
+ // is the single source of truth for this check and is also enforced a
184
+ // second time inside `upsertCustomProvider`/`deleteCustomProvider`
185
+ // themselves (defense in depth) — calling it here too gives a clear,
186
+ // early error before any other work happens.
187
+ //
188
+ // When the caller has no active org (`ctx.orgId` is null — a solo user,
189
+ // or an app that hasn't wired `resolveOrgId` at all), `scopeId` above
190
+ // already collapsed to `ctx.userEmail`: there is no shared org resource
191
+ // to protect, and no *other* caller can ever address that same scopeId
192
+ // (every other request's fallback is scoped to *its own* email). Treat
193
+ // that case like sole ownership of a personal bucket — consistent with
194
+ // `org/context.ts`'s auto-created personal org, which also assigns the
195
+ // user role "owner" — rather than hard-rejecting scope: "org" (the
196
+ // action's own default) for every solo user or org-less app.
197
+ let orgRole: string | null = null;
198
+ if ((operation === "upsert" || operation === "delete") && scope === "org") {
199
+ orgRole = ctx.orgId
200
+ ? await resolveCallerOrgRole(ctx.orgId, ctx.userEmail)
201
+ : "owner";
202
+ assertCanMutateCustomProviderScope(scope, scopeId, orgRole);
203
+ }
204
+
141
205
  if (operation === "list") {
142
206
  const providers = await listCustomProviders(scope, scopeId);
143
207
  return {
@@ -165,7 +229,7 @@ After registration the provider appears in provider-api-catalog and can be used
165
229
 
166
230
  if (operation === "delete") {
167
231
  if (!id) throw new Error("id is required for delete operation.");
168
- const deleted = await deleteCustomProvider(scope, scopeId, id);
232
+ const deleted = await deleteCustomProvider(scope, scopeId, id, orgRole);
169
233
  return { deleted, id };
170
234
  }
171
235
 
@@ -186,6 +250,7 @@ After registration the provider appears in provider-api-catalog and can be used
186
250
  allowedHostSuffixes,
187
251
  defaultHeaders,
188
252
  notes,
253
+ orgRole,
189
254
  });
190
255
 
191
256
  return {
@@ -0,0 +1,73 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ discoverAgents: vi.fn(),
5
+ setAccess: vi.fn(),
6
+ recordAudit: vi.fn(),
7
+ listAccess: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("@agent-native/core/server/agent-discovery", () => ({
11
+ discoverAgents: mocks.discoverAgents,
12
+ }));
13
+
14
+ vi.mock("../server/lib/mcp-access-store.js", () => ({
15
+ setDispatchMcpAppAccessSettings: mocks.setAccess,
16
+ }));
17
+
18
+ vi.mock("../server/lib/dispatch-store.js", () => ({
19
+ recordAudit: mocks.recordAudit,
20
+ }));
21
+
22
+ vi.mock("./list-mcp-app-access.js", () => ({
23
+ default: { run: mocks.listAccess },
24
+ }));
25
+
26
+ import setMcpAppAccess from "./set-mcp-app-access.js";
27
+
28
+ beforeEach(() => {
29
+ vi.clearAllMocks();
30
+ mocks.discoverAgents.mockResolvedValue([
31
+ { id: "analytics" },
32
+ { id: "slides" },
33
+ ]);
34
+ mocks.setAccess.mockResolvedValue(undefined);
35
+ mocks.recordAudit.mockResolvedValue(undefined);
36
+ mocks.listAccess.mockResolvedValue({ mode: "selected-apps" });
37
+ });
38
+
39
+ describe("set-mcp-app-access", () => {
40
+ it("accepts Dispatch itself in selected-app mode", async () => {
41
+ await setMcpAppAccess.run({
42
+ mode: "selected-apps",
43
+ selectedAppIds: ["Dispatch", "slides"],
44
+ });
45
+
46
+ expect(mocks.setAccess).toHaveBeenCalledWith({
47
+ mode: "selected-apps",
48
+ selectedAppIds: ["dispatch", "slides"],
49
+ });
50
+ });
51
+
52
+ it("allows an owner or admin to revoke every app grant", async () => {
53
+ await setMcpAppAccess.run({
54
+ mode: "selected-apps",
55
+ selectedAppIds: [],
56
+ });
57
+
58
+ expect(mocks.setAccess).toHaveBeenCalledWith({
59
+ mode: "selected-apps",
60
+ selectedAppIds: [],
61
+ });
62
+ });
63
+
64
+ it("rejects unknown app ids before changing access", async () => {
65
+ await expect(
66
+ setMcpAppAccess.run({
67
+ mode: "selected-apps",
68
+ selectedAppIds: ["unknown"],
69
+ }),
70
+ ).rejects.toThrow(/Unknown app/);
71
+ expect(mocks.setAccess).not.toHaveBeenCalled();
72
+ });
73
+ });
@@ -22,7 +22,7 @@ export default defineAction({
22
22
  }),
23
23
  run: async (args) => {
24
24
  const agents = await discoverAgents("dispatch");
25
- const knownIds = new Set(agents.map((agent) => agent.id));
25
+ const knownIds = new Set(["dispatch", ...agents.map((agent) => agent.id)]);
26
26
  const selectedAppIds = Array.from(
27
27
  new Set(
28
28
  args.selectedAppIds
@@ -36,10 +36,6 @@ export default defineAction({
36
36
  `Unknown app(s): ${unknown.join(", ")}. Use list-mcp-app-access to see available app IDs.`,
37
37
  );
38
38
  }
39
- if (args.mode === "selected-apps" && selectedAppIds.length === 0) {
40
- throw new Error("selected-apps mode requires at least one app ID.");
41
- }
42
-
43
39
  await setDispatchMcpAppAccessSettings({
44
40
  mode: args.mode,
45
41
  selectedAppIds,
@@ -0,0 +1,56 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { ActionQueryError } from "./action-query-error";
7
+
8
+ vi.mock("@agent-native/core/client", () => ({
9
+ useT: () => (key: string) =>
10
+ ({
11
+ "dispatch.pages.dataLoadFailed": "Couldn't load data",
12
+ "dispatch.pages.dataLoadFailedDescription":
13
+ "Dispatch couldn't load this data.",
14
+ "dispatch.pages.tryAgain": "Try again",
15
+ })[key] ?? key,
16
+ }));
17
+
18
+ describe("ActionQueryError", () => {
19
+ let container: HTMLDivElement;
20
+ let root: Root;
21
+
22
+ beforeEach(() => {
23
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
24
+ container = document.createElement("div");
25
+ document.body.appendChild(container);
26
+ root = createRoot(container);
27
+ });
28
+
29
+ afterEach(() => {
30
+ act(() => root.unmount());
31
+ container.remove();
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ it("shows a safe query error and retries on request", () => {
36
+ const onRetry = vi.fn();
37
+ act(() => {
38
+ root.render(
39
+ <ActionQueryError
40
+ error={new Error("Database unavailable")}
41
+ onRetry={onRetry}
42
+ />,
43
+ );
44
+ });
45
+
46
+ expect(container.textContent).toContain("Couldn't load data");
47
+ expect(container.textContent).toContain(
48
+ "Dispatch couldn't load this data.",
49
+ );
50
+ expect(container.textContent).not.toContain("Database unavailable");
51
+ act(() => {
52
+ container.querySelector("button")?.click();
53
+ });
54
+ expect(onRetry).toHaveBeenCalledOnce();
55
+ });
56
+ });
@@ -0,0 +1,32 @@
1
+ import { useT } from "@agent-native/core/client";
2
+ import { IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
3
+
4
+ import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
5
+ import { Button } from "./ui/button";
6
+
7
+ interface ActionQueryErrorProps {
8
+ error?: unknown;
9
+ onRetry: () => unknown;
10
+ className?: string;
11
+ }
12
+
13
+ export function ActionQueryError({
14
+ onRetry,
15
+ className,
16
+ }: ActionQueryErrorProps) {
17
+ const t = useT();
18
+
19
+ return (
20
+ <Alert variant="destructive" className={className}>
21
+ <IconAlertTriangle className="size-4" />
22
+ <AlertTitle>{t("dispatch.pages.dataLoadFailed")}</AlertTitle>
23
+ <AlertDescription className="flex flex-wrap items-center justify-between gap-3">
24
+ <span>{t("dispatch.pages.dataLoadFailedDescription")}</span>
25
+ <Button type="button" variant="outline" size="sm" onClick={onRetry}>
26
+ <IconRefresh className="size-4" />
27
+ {t("dispatch.pages.tryAgain")}
28
+ </Button>
29
+ </AlertDescription>
30
+ </Alert>
31
+ );
32
+ }
@@ -0,0 +1,85 @@
1
+ // @vitest-environment happy-dom
2
+ import React, { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import { AppKeysPanel } from "./app-keys-popover";
7
+
8
+ const queryState = vi.hoisted(() => ({
9
+ refetchSecrets: vi.fn(),
10
+ refetchGrants: vi.fn(),
11
+ refetchAccess: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("@agent-native/core/client", () => ({
15
+ useT: () => (key: string) =>
16
+ ({
17
+ "dispatch.pages.dataLoadFailed": "Couldn't load data",
18
+ "dispatch.pages.dataLoadFailedDescription":
19
+ "Dispatch couldn't load this data.",
20
+ "dispatch.pages.tryAgain": "Try again",
21
+ })[key] ?? key,
22
+ useActionQuery: (name: string) => {
23
+ const queries = {
24
+ "list-vault-secret-options": {
25
+ data: undefined,
26
+ isLoading: false,
27
+ error: new Error("Vault unavailable"),
28
+ refetch: queryState.refetchSecrets,
29
+ },
30
+ "list-vault-grants": {
31
+ data: [],
32
+ isLoading: false,
33
+ error: null,
34
+ refetch: queryState.refetchGrants,
35
+ },
36
+ "get-vault-access-settings": {
37
+ data: undefined,
38
+ isLoading: false,
39
+ error: null,
40
+ refetch: queryState.refetchAccess,
41
+ },
42
+ };
43
+ return queries[name as keyof typeof queries];
44
+ },
45
+ useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
46
+ }));
47
+
48
+ describe("AppKeysPanel", () => {
49
+ let container: HTMLDivElement;
50
+ let root: Root;
51
+
52
+ beforeEach(() => {
53
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
54
+ vi.clearAllMocks();
55
+ container = document.createElement("div");
56
+ document.body.appendChild(container);
57
+ root = createRoot(container);
58
+ });
59
+
60
+ afterEach(() => {
61
+ act(() => root.unmount());
62
+ container.remove();
63
+ vi.unstubAllGlobals();
64
+ });
65
+
66
+ it("shows a retryable error instead of an empty key list", () => {
67
+ act(() => {
68
+ root.render(<AppKeysPanel appId="mail" appName="Mail" />);
69
+ });
70
+
71
+ expect(container.textContent).toContain("Couldn't load data");
72
+ expect(container.textContent).toContain(
73
+ "Dispatch couldn't load this data.",
74
+ );
75
+ expect(container.textContent).not.toContain("Vault unavailable");
76
+ expect(container.textContent).not.toContain("No vault keys yet");
77
+
78
+ act(() => {
79
+ container.querySelector("button")?.click();
80
+ });
81
+ expect(queryState.refetchSecrets).toHaveBeenCalledOnce();
82
+ expect(queryState.refetchGrants).toHaveBeenCalledOnce();
83
+ expect(queryState.refetchAccess).toHaveBeenCalledOnce();
84
+ });
85
+ });