@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
@@ -17,6 +17,7 @@ import {
17
17
  import { useEffect, useState, type ReactNode } from "react";
18
18
  import { toast } from "sonner";
19
19
 
20
+ import { ActionQueryError } from "../../components/action-query-error";
20
21
  import { DispatchShell } from "../../components/dispatch-shell";
21
22
  import {
22
23
  AlertDialog,
@@ -1014,11 +1015,10 @@ function GlobalContextSection({ resources }: { resources: any[] }) {
1014
1015
  }
1015
1016
 
1016
1017
  export default function WorkspaceRoute() {
1017
- const { data: resources, isLoading } = useActionQuery(
1018
- "list-workspace-resources",
1019
- {},
1020
- );
1021
- const { data: grants } = useActionQuery("list-workspace-resource-grants", {});
1018
+ const resourcesQuery = useActionQuery("list-workspace-resources", {});
1019
+ const grantsQuery = useActionQuery("list-workspace-resource-grants", {});
1020
+ const { data: resources, isLoading } = resourcesQuery;
1021
+ const { data: grants } = grantsQuery;
1022
1022
 
1023
1023
  const grantsByResource = (grants || []).reduce(
1024
1024
  (acc: Record<string, any[]>, g: any) => {
@@ -1088,6 +1088,15 @@ export default function WorkspaceRoute() {
1088
1088
  title="Workspace Resources"
1089
1089
  description="Manage inherited workspace skills, guardrail instructions, agent profiles, reference resources, and MCP servers. All-app resources are available to every app without syncing."
1090
1090
  >
1091
+ {resourcesQuery.isError || grantsQuery.isError ? (
1092
+ <ActionQueryError
1093
+ error={resourcesQuery.error ?? grantsQuery.error}
1094
+ onRetry={() => {
1095
+ void resourcesQuery.refetch();
1096
+ void grantsQuery.refetch();
1097
+ }}
1098
+ />
1099
+ ) : null}
1091
1100
  <div className="flex items-center justify-between">
1092
1101
  <div className="text-sm text-muted-foreground">
1093
1102
  {isLoading ? (
@@ -1101,62 +1110,67 @@ export default function WorkspaceRoute() {
1101
1110
  </div>
1102
1111
  </div>
1103
1112
 
1104
- <GlobalContextSection resources={resources || []} />
1113
+ {!resourcesQuery.isError ? (
1114
+ <GlobalContextSection resources={resources || []} />
1115
+ ) : null}
1105
1116
 
1106
- <Tabs defaultValue="skills">
1107
- <TabsList>
1108
- <TabsTrigger value="skills">
1109
- Skills {skills.length > 0 && `(${skills.length})`}
1110
- </TabsTrigger>
1111
- <TabsTrigger value="instructions">
1112
- Instructions {instructions.length > 0 && `(${instructions.length})`}
1113
- </TabsTrigger>
1114
- <TabsTrigger value="agents">
1115
- Agents {agents.length > 0 && `(${agents.length})`}
1116
- </TabsTrigger>
1117
- <TabsTrigger value="knowledge">
1118
- Knowledge {knowledge.length > 0 && `(${knowledge.length})`}
1119
- </TabsTrigger>
1120
- <TabsTrigger value="mcp">
1121
- MCP {mcpServers.length > 0 && `(${mcpServers.length})`}
1122
- </TabsTrigger>
1123
- </TabsList>
1117
+ {!resourcesQuery.isError && !grantsQuery.isError ? (
1118
+ <Tabs defaultValue="skills">
1119
+ <TabsList>
1120
+ <TabsTrigger value="skills">
1121
+ Skills {skills.length > 0 && `(${skills.length})`}
1122
+ </TabsTrigger>
1123
+ <TabsTrigger value="instructions">
1124
+ Instructions{" "}
1125
+ {instructions.length > 0 && `(${instructions.length})`}
1126
+ </TabsTrigger>
1127
+ <TabsTrigger value="agents">
1128
+ Agents {agents.length > 0 && `(${agents.length})`}
1129
+ </TabsTrigger>
1130
+ <TabsTrigger value="knowledge">
1131
+ Knowledge {knowledge.length > 0 && `(${knowledge.length})`}
1132
+ </TabsTrigger>
1133
+ <TabsTrigger value="mcp">
1134
+ MCP {mcpServers.length > 0 && `(${mcpServers.length})`}
1135
+ </TabsTrigger>
1136
+ </TabsList>
1124
1137
 
1125
- <TabsContent value="skills" className="mt-4">
1126
- <ResourceList
1127
- items={skills}
1128
- emptyText="No workspace skills yet. Add a skill to share agent guidance across apps."
1129
- />
1130
- </TabsContent>
1138
+ <TabsContent value="skills" className="mt-4">
1139
+ <ResourceList
1140
+ items={skills}
1141
+ emptyText="No workspace skills yet. Add a skill to share agent guidance across apps."
1142
+ />
1143
+ </TabsContent>
1131
1144
 
1132
- <TabsContent value="instructions" className="mt-4">
1133
- <ResourceList
1134
- items={instructions}
1135
- emptyText="No workspace instructions yet. Add instructions to set behavioral rules across apps."
1136
- />
1137
- </TabsContent>
1145
+ <TabsContent value="instructions" className="mt-4">
1146
+ <ResourceList
1147
+ items={instructions}
1148
+ emptyText="No workspace instructions yet. Add instructions to set behavioral rules across apps."
1149
+ />
1150
+ </TabsContent>
1138
1151
 
1139
- <TabsContent value="agents" className="mt-4">
1140
- <ResourceList
1141
- items={agents}
1142
- emptyText="No workspace agents yet. Add a reusable agent profile to share specialist agents across apps."
1143
- />
1144
- </TabsContent>
1152
+ <TabsContent value="agents" className="mt-4">
1153
+ <ResourceList
1154
+ items={agents}
1155
+ emptyText="No workspace agents yet. Add a reusable agent profile to share specialist agents across apps."
1156
+ />
1157
+ </TabsContent>
1145
1158
 
1146
- <TabsContent value="knowledge" className="mt-4">
1147
- <ResourceList
1148
- items={knowledge}
1149
- emptyText="No knowledge packs yet. Add GTM, product, or domain context that apps can reuse."
1150
- />
1151
- </TabsContent>
1159
+ <TabsContent value="knowledge" className="mt-4">
1160
+ <ResourceList
1161
+ items={knowledge}
1162
+ emptyText="No knowledge packs yet. Add GTM, product, or domain context that apps can reuse."
1163
+ />
1164
+ </TabsContent>
1152
1165
 
1153
- <TabsContent value="mcp" className="mt-4">
1154
- <ResourceList
1155
- items={mcpServers}
1156
- emptyText="No workspace MCP servers yet. Add an HTTP MCP server to share external tools across apps."
1157
- />
1158
- </TabsContent>
1159
- </Tabs>
1166
+ <TabsContent value="mcp" className="mt-4">
1167
+ <ResourceList
1168
+ items={mcpServers}
1169
+ emptyText="No workspace MCP servers yet. Add an HTTP MCP server to share external tools across apps."
1170
+ />
1171
+ </TabsContent>
1172
+ </Tabs>
1173
+ ) : null}
1160
1174
  </DispatchShell>
1161
1175
  );
1162
1176
  }
@@ -0,0 +1,420 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ // Each test runs real migrations against a fresh SQLite file; under full
8
+ // workspace concurrency (and a shared machine running other suites) that
9
+ // setup can far exceed the 5s default, so give it generous headroom. The
10
+ // tests themselves complete in a few seconds uncontended.
11
+ vi.setConfig({ testTimeout: 60_000, hookTimeout: 60_000 });
12
+
13
+ const ownerEmail = "owner+approval-fencing@example.test";
14
+ const orgId = "org_approval_fencing";
15
+ const otherOwnerEmail = "owner+approval-fencing-other@example.test";
16
+ const otherOrgId = "org_approval_fencing_other";
17
+
18
+ const originalEnv = {
19
+ APP_NAME: process.env.APP_NAME,
20
+ DATABASE_URL: process.env.DATABASE_URL,
21
+ DATABASE_AUTH_TOKEN: process.env.DATABASE_AUTH_TOKEN,
22
+ DISPATCH_DATABASE_URL: process.env.DISPATCH_DATABASE_URL,
23
+ DISPATCH_DATABASE_AUTH_TOKEN: process.env.DISPATCH_DATABASE_AUTH_TOKEN,
24
+ };
25
+
26
+ let tempDir: string | null = null;
27
+
28
+ function restoreEnv() {
29
+ for (const [key, value] of Object.entries(originalEnv)) {
30
+ if (value === undefined) delete process.env[key];
31
+ else process.env[key] = value;
32
+ }
33
+ }
34
+
35
+ beforeEach(async () => {
36
+ tempDir = fs.mkdtempSync(
37
+ path.join(os.tmpdir(), "dispatch-approval-fencing-"),
38
+ );
39
+ process.env.DATABASE_URL = `file:${path.join(tempDir, "app.db")}`;
40
+ delete process.env.APP_NAME;
41
+ delete process.env.DATABASE_AUTH_TOKEN;
42
+ delete process.env.DISPATCH_DATABASE_URL;
43
+ delete process.env.DISPATCH_DATABASE_AUTH_TOKEN;
44
+ vi.resetModules();
45
+
46
+ const [{ runMigrations }, { dispatchMigrations }] = await Promise.all([
47
+ import("@agent-native/core/db"),
48
+ import("../../db/migrations.js"),
49
+ ]);
50
+ await runMigrations(dispatchMigrations, {
51
+ table: "dispatch_migrations",
52
+ })({});
53
+ });
54
+
55
+ afterEach(async () => {
56
+ try {
57
+ const { closeDbExec } = await import("@agent-native/core/db");
58
+ await closeDbExec();
59
+ } catch {}
60
+ restoreEnv();
61
+ if (tempDir) {
62
+ fs.rmSync(tempDir, { recursive: true, force: true });
63
+ tempDir = null;
64
+ }
65
+ });
66
+
67
+ describe("dispatch approval request status fencing", () => {
68
+ it("applies the change once when approveRequest is called twice on the same request", async () => {
69
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
70
+ await Promise.all([
71
+ import("@agent-native/core/server"),
72
+ import("@agent-native/core/db"),
73
+ import("./dispatch-store.js"),
74
+ ]);
75
+ const exec = getDbExec();
76
+
77
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
78
+ const created = await dispatchStore.createApprovalRequest({
79
+ changeType: "approval-policy.update",
80
+ targetType: "dispatch-settings",
81
+ targetId: "dispatch-approval-policy",
82
+ summary: "Enable approval policy",
83
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
84
+ });
85
+ const requestId = (created as any).id;
86
+
87
+ const first = await dispatchStore.approveRequest(requestId);
88
+ expect(first.status).toBe("approved");
89
+ expect(first.reviewedBy).toBe(ownerEmail);
90
+ expect(first.reviewedAt).toBeTruthy();
91
+
92
+ expect(await dispatchStore.getApprovalPolicy()).toEqual({
93
+ enabled: true,
94
+ approverEmails: ["reviewer@example.test"],
95
+ });
96
+
97
+ const approvedAuditCount = async () => {
98
+ const rows = await exec.execute({
99
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.approved' AND target_id = ?",
100
+ args: [requestId],
101
+ });
102
+ return Number((rows.rows[0] as any).count);
103
+ };
104
+ expect(await approvedAuditCount()).toBe(1);
105
+
106
+ // A concurrent second approve landing after the first already won the
107
+ // race must find the row no longer 'pending': the fenced UPDATE
108
+ // affects zero rows, so it must not re-apply the change.
109
+ const second = await dispatchStore.approveRequest(requestId);
110
+ expect(second.status).toBe("approved");
111
+ expect(second.reviewedAt).toBe(first.reviewedAt);
112
+ expect(await approvedAuditCount()).toBe(1);
113
+ });
114
+ });
115
+
116
+ it("reclaims a stale applying lease after a worker crash", async () => {
117
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
118
+ await Promise.all([
119
+ import("@agent-native/core/server"),
120
+ import("@agent-native/core/db"),
121
+ import("./dispatch-store.js"),
122
+ ]);
123
+ const exec = getDbExec();
124
+
125
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
126
+ const created = await dispatchStore.createApprovalRequest({
127
+ changeType: "approval-policy.update",
128
+ targetType: "dispatch-settings",
129
+ targetId: "dispatch-approval-policy",
130
+ summary: "Enable approval policy after recovery",
131
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
132
+ });
133
+ const requestId = (created as any).id;
134
+
135
+ await exec.execute({
136
+ sql: "UPDATE dispatch_approval_requests SET status = ?, updated_at = ? WHERE id = ?",
137
+ args: ["applying", Date.now() - 6 * 60 * 1000, requestId],
138
+ });
139
+
140
+ const recovered = await dispatchStore.approveRequest(requestId);
141
+ expect(recovered.status).toBe("approved");
142
+ expect(recovered.reviewedBy).toBe(ownerEmail);
143
+ });
144
+ });
145
+
146
+ it("keeps a failed apply leased instead of returning it to pending", async () => {
147
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
148
+ await Promise.all([
149
+ import("@agent-native/core/server"),
150
+ import("@agent-native/core/db"),
151
+ import("./dispatch-store.js"),
152
+ ]);
153
+ const exec = getDbExec();
154
+
155
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
156
+ const created = await dispatchStore.createApprovalRequest({
157
+ changeType: "unsupported.partial-effect",
158
+ targetType: "test",
159
+ summary: "Do not immediately retry ambiguous work",
160
+ payload: {},
161
+ });
162
+ const requestId = (created as any).id;
163
+
164
+ await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
165
+ "Unsupported approval request type",
166
+ );
167
+
168
+ const rows = await exec.execute({
169
+ sql: "SELECT status FROM dispatch_approval_requests WHERE id = ?",
170
+ args: [requestId],
171
+ });
172
+ expect(rows.rows[0]).toMatchObject({ status: "applying" });
173
+ });
174
+ });
175
+
176
+ it("rejects the change once when rejectRequest is called twice on the same request", async () => {
177
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
178
+ await Promise.all([
179
+ import("@agent-native/core/server"),
180
+ import("@agent-native/core/db"),
181
+ import("./dispatch-store.js"),
182
+ ]);
183
+ const exec = getDbExec();
184
+
185
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
186
+ const created = await dispatchStore.createApprovalRequest({
187
+ changeType: "approval-policy.update",
188
+ targetType: "dispatch-settings",
189
+ targetId: "dispatch-approval-policy",
190
+ summary: "Disable approval policy",
191
+ payload: { enabled: false, approverEmails: [] },
192
+ });
193
+ const requestId = (created as any).id;
194
+
195
+ const first = await dispatchStore.rejectRequest(requestId, "not needed");
196
+ expect(first?.status).toBe("rejected");
197
+ expect(first?.reviewedAt).toBeTruthy();
198
+
199
+ const rejectedAuditCount = async () => {
200
+ const rows = await exec.execute({
201
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.rejected' AND target_id = ?",
202
+ args: [requestId],
203
+ });
204
+ return Number((rows.rows[0] as any).count);
205
+ };
206
+ expect(await rejectedAuditCount()).toBe(1);
207
+
208
+ const second = await dispatchStore.rejectRequest(
209
+ requestId,
210
+ "still not needed",
211
+ );
212
+ expect(second?.status).toBe("rejected");
213
+ expect(second?.reviewedAt).toBe(first?.reviewedAt);
214
+ expect(await rejectedAuditCount()).toBe(1);
215
+ });
216
+ });
217
+
218
+ it("does not let a caller from a different tenant approve another tenant's request", async () => {
219
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
220
+ await Promise.all([
221
+ import("@agent-native/core/server"),
222
+ import("@agent-native/core/db"),
223
+ import("./dispatch-store.js"),
224
+ ]);
225
+ const exec = getDbExec();
226
+
227
+ const requestId = await runWithRequestContext(
228
+ { userEmail: ownerEmail, orgId },
229
+ async () => {
230
+ const created = await dispatchStore.createApprovalRequest({
231
+ changeType: "approval-policy.update",
232
+ targetType: "dispatch-settings",
233
+ targetId: "dispatch-approval-policy",
234
+ summary: "Enable approval policy for tenant A",
235
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
236
+ });
237
+ return (created as any).id as string;
238
+ },
239
+ );
240
+
241
+ await runWithRequestContext(
242
+ { userEmail: otherOwnerEmail, orgId: otherOrgId },
243
+ async () => {
244
+ await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
245
+ "Approval request not found",
246
+ );
247
+ },
248
+ );
249
+
250
+ const rows = await exec.execute({
251
+ sql: "SELECT status, reviewed_by, reviewed_at FROM dispatch_approval_requests WHERE id = ?",
252
+ args: [requestId],
253
+ });
254
+ expect(rows.rows[0]).toMatchObject({
255
+ status: "pending",
256
+ reviewed_by: null,
257
+ reviewed_at: null,
258
+ });
259
+
260
+ const approvedAuditRows = await exec.execute({
261
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.approved' AND target_id = ?",
262
+ args: [requestId],
263
+ });
264
+ expect(Number((approvedAuditRows.rows[0] as any).count)).toBe(0);
265
+
266
+ // Confirm no side effect landed either: the policy change must not have
267
+ // been applied to tenant A's org settings by the foreign-tenant attempt.
268
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
269
+ expect(await dispatchStore.getApprovalPolicy()).toEqual({
270
+ enabled: false,
271
+ approverEmails: [],
272
+ });
273
+ });
274
+ });
275
+
276
+ it("does not let a caller from a different tenant reject another tenant's request", async () => {
277
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
278
+ await Promise.all([
279
+ import("@agent-native/core/server"),
280
+ import("@agent-native/core/db"),
281
+ import("./dispatch-store.js"),
282
+ ]);
283
+ const exec = getDbExec();
284
+
285
+ const requestId = await runWithRequestContext(
286
+ { userEmail: ownerEmail, orgId },
287
+ async () => {
288
+ const created = await dispatchStore.createApprovalRequest({
289
+ changeType: "approval-policy.update",
290
+ targetType: "dispatch-settings",
291
+ targetId: "dispatch-approval-policy",
292
+ summary: "Enable approval policy for tenant A (reject case)",
293
+ payload: { enabled: true, approverEmails: ["reviewer@example.test"] },
294
+ });
295
+ return (created as any).id as string;
296
+ },
297
+ );
298
+
299
+ await runWithRequestContext(
300
+ { userEmail: otherOwnerEmail, orgId: otherOrgId },
301
+ async () => {
302
+ await expect(
303
+ dispatchStore.rejectRequest(requestId, "not my call"),
304
+ ).rejects.toThrow("Approval request not found");
305
+ },
306
+ );
307
+
308
+ const rows = await exec.execute({
309
+ sql: "SELECT status, reviewed_by, reviewed_at FROM dispatch_approval_requests WHERE id = ?",
310
+ args: [requestId],
311
+ });
312
+ expect(rows.rows[0]).toMatchObject({
313
+ status: "pending",
314
+ reviewed_by: null,
315
+ reviewed_at: null,
316
+ });
317
+
318
+ const rejectedAuditRows = await exec.execute({
319
+ sql: "SELECT COUNT(*) as count FROM dispatch_audit_events WHERE action = 'approval.rejected' AND target_id = ?",
320
+ args: [requestId],
321
+ });
322
+ expect(Number((rejectedAuditRows.rows[0] as any).count)).toBe(0);
323
+ });
324
+ });
325
+
326
+ describe("vault request status fencing", () => {
327
+ it("does not duplicate a grant when createGrant is retried", async () => {
328
+ const [{ runWithRequestContext }, vaultStore] = await Promise.all([
329
+ import("@agent-native/core/server"),
330
+ import("./vault-store.js"),
331
+ ]);
332
+
333
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
334
+ const secret = await vaultStore.createSecret({
335
+ credentialKey: "RETRY_GRANT_API_KEY",
336
+ value: "secret-value",
337
+ name: "Retry Grant Secret",
338
+ });
339
+ expect(secret).toBeTruthy();
340
+
341
+ const first = await vaultStore.createGrant(secret!.id, "test-app");
342
+ const retry = await vaultStore.createGrant(secret!.id, "test-app");
343
+
344
+ expect(retry?.id).toBe(first?.id);
345
+ expect(await vaultStore.listGrants({ appId: "test-app" })).toHaveLength(
346
+ 1,
347
+ );
348
+ });
349
+ });
350
+
351
+ it("creates a single grant when approveRequest is called twice on the same request", async () => {
352
+ const [{ runWithRequestContext }, vaultStore] = await Promise.all([
353
+ import("@agent-native/core/server"),
354
+ import("./vault-store.js"),
355
+ ]);
356
+
357
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
358
+ const created = await vaultStore.createRequest({
359
+ credentialKey: "TEST_API_KEY",
360
+ appId: "test-app",
361
+ reason: "needed for tests",
362
+ });
363
+ const requestId = (created as any).id;
364
+
365
+ const first = await vaultStore.approveRequest(
366
+ requestId,
367
+ "secret-value-1",
368
+ "Test Secret",
369
+ );
370
+ expect(first?.status).toBe("approved");
371
+
372
+ const grantsAfterFirst = await vaultStore.listGrants({
373
+ appId: "test-app",
374
+ });
375
+ expect(grantsAfterFirst).toHaveLength(1);
376
+
377
+ // Loser of the race: the row is already 'approved', so this must not
378
+ // create a second grant for the same request.
379
+ const second = await vaultStore.approveRequest(
380
+ requestId,
381
+ "secret-value-2",
382
+ "Test Secret",
383
+ );
384
+ expect(second?.status).toBe("approved");
385
+
386
+ const grantsAfterSecond = await vaultStore.listGrants({
387
+ appId: "test-app",
388
+ });
389
+ expect(grantsAfterSecond).toHaveLength(1);
390
+ expect(grantsAfterSecond[0].id).toBe(grantsAfterFirst[0].id);
391
+ });
392
+ });
393
+
394
+ it("denies once when denyRequest is called twice on the same request", async () => {
395
+ const [{ runWithRequestContext }, vaultStore] = await Promise.all([
396
+ import("@agent-native/core/server"),
397
+ import("./vault-store.js"),
398
+ ]);
399
+
400
+ await runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
401
+ const created = await vaultStore.createRequest({
402
+ credentialKey: "OTHER_API_KEY",
403
+ appId: "test-app",
404
+ reason: "needed for tests",
405
+ });
406
+ const requestId = (created as any).id;
407
+
408
+ const first = await vaultStore.denyRequest(requestId, "not approved");
409
+ expect(first?.status).toBe("denied");
410
+ expect(first?.reviewedAt).toBeTruthy();
411
+
412
+ const second = await vaultStore.denyRequest(
413
+ requestId,
414
+ "still not approved",
415
+ );
416
+ expect(second?.status).toBe("denied");
417
+ expect(second?.reviewedAt).toBe(first?.reviewedAt);
418
+ });
419
+ });
420
+ });