@agent-native/dispatch 0.29.5 → 0.30.1

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 (35) 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/provider-api-register.d.ts +8 -8
  4. package/dist/actions/update-workspace-resource.d.ts +13 -13
  5. package/dist/components/workspace-app-card.d.ts.map +1 -1
  6. package/dist/components/workspace-app-card.js +2 -1
  7. package/dist/components/workspace-app-card.js.map +1 -1
  8. package/dist/db/index.d.ts.map +1 -1
  9. package/dist/db/index.js +26 -1
  10. package/dist/db/index.js.map +1 -1
  11. package/dist/db/schema.d.ts +1 -0
  12. package/dist/db/schema.d.ts.map +1 -1
  13. package/dist/db/schema.js +4 -0
  14. package/dist/db/schema.js.map +1 -1
  15. package/dist/lib/workspace-apps.d.ts +1 -0
  16. package/dist/lib/workspace-apps.d.ts.map +1 -1
  17. package/dist/lib/workspace-apps.js +3 -1
  18. package/dist/lib/workspace-apps.js.map +1 -1
  19. package/dist/lib/workspace-apps.test.js +17 -3
  20. package/dist/lib/workspace-apps.test.js.map +1 -1
  21. package/dist/server/lib/app-creation-store.d.ts +3 -0
  22. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  23. package/dist/server/lib/app-creation-store.js +206 -11
  24. package/dist/server/lib/app-creation-store.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/components/dispatch-control-plane.spec.tsx +17 -3
  27. package/src/components/workspace-app-card.spec.tsx +17 -0
  28. package/src/components/workspace-app-card.tsx +12 -0
  29. package/src/components/workspace-app-host.spec.tsx +14 -6
  30. package/src/db/index.ts +28 -1
  31. package/src/db/schema.ts +5 -0
  32. package/src/lib/workspace-apps.test.ts +20 -2
  33. package/src/lib/workspace-apps.ts +4 -1
  34. package/src/server/lib/app-creation-store.spec.ts +120 -0
  35. package/src/server/lib/app-creation-store.ts +264 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/dispatch",
3
- "version": "0.29.5",
3
+ "version": "0.30.1",
4
4
  "description": "Dispatch — workspace control plane for agent-native apps. Vault, integrations, destinations, scheduled jobs, and cross-app delegation, shipped as a single drop-in package.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -87,7 +87,7 @@
87
87
  "tailwind-merge": "^3.5.0",
88
88
  "vaul": "^1.1.2",
89
89
  "zod": "^4.3.6",
90
- "@agent-native/toolkit": "0.16.5"
90
+ "@agent-native/toolkit": "0.16.6"
91
91
  },
92
92
  "devDependencies": {
93
93
  "@react-router/dev": "^8.1.0",
@@ -101,7 +101,7 @@
101
101
  "typescript-7": "npm:typescript@^7.0.2",
102
102
  "vite": "8.1.0",
103
103
  "vitest": "^4.1.5",
104
- "@agent-native/core": "0.162.0"
104
+ "@agent-native/core": "0.164.1"
105
105
  },
106
106
  "peerDependencies": {
107
107
  "@agent-native/core": ">=0.8.0",
@@ -1,4 +1,5 @@
1
1
  // @vitest-environment happy-dom
2
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2
3
  import React, { act } from "react";
3
4
  import { createRoot, type Root } from "react-dom/client";
4
5
  import { MemoryRouter } from "react-router";
@@ -99,6 +100,7 @@ vi.mock("./create-app-popover", () => ({
99
100
  describe("DispatchControlPlane", () => {
100
101
  let container: HTMLDivElement;
101
102
  let root: Root;
103
+ let queryClient: QueryClient;
102
104
 
103
105
  beforeEach(() => {
104
106
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
@@ -109,6 +111,12 @@ describe("DispatchControlPlane", () => {
109
111
  clientState.connectedApps = [];
110
112
  clientState.curatedTemplates = [];
111
113
  clientState.useChatModels.mockClear();
114
+ queryClient = new QueryClient({
115
+ defaultOptions: {
116
+ queries: { retry: false },
117
+ mutations: { retry: false },
118
+ },
119
+ });
112
120
  container = document.createElement("div");
113
121
  document.body.appendChild(container);
114
122
  root = createRoot(container);
@@ -125,7 +133,9 @@ describe("DispatchControlPlane", () => {
125
133
  root.render(
126
134
  <MemoryRouter initialEntries={["/overview"]}>
127
135
  <TooltipProvider>
128
- <DispatchControlPlane />
136
+ <QueryClientProvider client={queryClient}>
137
+ <DispatchControlPlane />
138
+ </QueryClientProvider>
129
139
  </TooltipProvider>
130
140
  </MemoryRouter>,
131
141
  );
@@ -189,7 +199,9 @@ describe("DispatchControlPlane", () => {
189
199
  root.render(
190
200
  <MemoryRouter initialEntries={["/overview"]}>
191
201
  <TooltipProvider>
192
- <DispatchControlPlane />
202
+ <QueryClientProvider client={queryClient}>
203
+ <DispatchControlPlane />
204
+ </QueryClientProvider>
193
205
  </TooltipProvider>
194
206
  </MemoryRouter>,
195
207
  );
@@ -279,7 +291,9 @@ describe("DispatchControlPlane", () => {
279
291
  root.render(
280
292
  <MemoryRouter initialEntries={["/overview"]}>
281
293
  <TooltipProvider>
282
- <DispatchControlPlane />
294
+ <QueryClientProvider client={queryClient}>
295
+ <DispatchControlPlane />
296
+ </QueryClientProvider>
283
297
  </TooltipProvider>
284
298
  </MemoryRouter>,
285
299
  );
@@ -7,6 +7,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
7
  import { TooltipProvider } from "./ui/tooltip";
8
8
  import { WorkspaceAppCard } from "./workspace-app-card";
9
9
 
10
+ vi.mock("@agent-native/core/client/sharing", () => ({
11
+ ShareButton: ({ resourceTitle }: { resourceTitle?: string }) =>
12
+ React.createElement(
13
+ "button",
14
+ { type: "button", "aria-label": `Share ${resourceTitle ?? "app"}` },
15
+ "Share",
16
+ ),
17
+ }));
18
+
10
19
  vi.mock("@agent-native/core/client/hooks", () => ({
11
20
  useActionMutation: () => ({ mutate: vi.fn(), isPending: false }),
12
21
  useActionQuery: () => ({
@@ -26,11 +35,18 @@ vi.mock("@agent-native/core/client/i18n", () => ({
26
35
  String(values?.defaultValue ?? key),
27
36
  }));
28
37
 
38
+ const frameState = vi.hoisted(() => ({ inBuilderFrame: false }));
39
+
40
+ vi.mock("@agent-native/core/client/host", () => ({
41
+ isInBuilderFrame: () => frameState.inBuilderFrame,
42
+ }));
43
+
29
44
  describe("WorkspaceAppCard", () => {
30
45
  let container: HTMLDivElement;
31
46
  let root: Root;
32
47
 
33
48
  beforeEach(() => {
49
+ frameState.inBuilderFrame = false;
34
50
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
35
51
  container = document.createElement("div");
36
52
  document.body.appendChild(container);
@@ -118,6 +134,7 @@ describe("WorkspaceAppCard", () => {
118
134
  });
119
135
 
120
136
  it("opens mounted workspace apps at their published URL", async () => {
137
+ frameState.inBuilderFrame = true;
121
138
  const originalParent = window.parent;
122
139
  const originalTop = window.top;
123
140
  const topWindow = { location: { href: "" } } as unknown as Window;
@@ -4,6 +4,7 @@ import {
4
4
  useActionQuery,
5
5
  } from "@agent-native/core/client/hooks";
6
6
  import { useT } from "@agent-native/core/client/i18n";
7
+ import { ShareButton } from "@agent-native/core/client/sharing";
7
8
  import {
8
9
  IconChevronDown,
9
10
  IconChevronRight,
@@ -210,6 +211,17 @@ export function WorkspaceAppCard({
210
211
  pendingOpenLabel={pendingOpenLabel}
211
212
  onTogglePinned={onTogglePinned}
212
213
  />
214
+ {!isPending ? (
215
+ <div className="pointer-events-auto">
216
+ <ShareButton
217
+ resourceType="workspace-app"
218
+ resourceId={app.id}
219
+ resourceTitle={app.name}
220
+ trigger="icon"
221
+ triggerClassName={APP_CARD_ACTION_CLASS}
222
+ />
223
+ </div>
224
+ ) : null}
213
225
  <WorkspaceAppSettings
214
226
  app={app}
215
227
  isArchived={isArchived}
@@ -382,8 +382,9 @@ describe("WorkspaceAppKeepAlive", () => {
382
382
  expect(clientState.workspaceSsoMutateAsync).not.toHaveBeenCalled();
383
383
  });
384
384
 
385
- it("mints custom SSO before navigating an embedded Dispatch surface", async () => {
385
+ it("mints custom SSO before navigating a Builder-hosted surface", async () => {
386
386
  clientState.workspaceSsoEnabled = true;
387
+ clientState.inBuilderFrame = true;
387
388
  const navigateToTopWindow = vi.fn(() => true);
388
389
  Object.defineProperty(window, "parent", {
389
390
  configurable: true,
@@ -535,9 +536,8 @@ describe("WorkspaceAppKeepAlive", () => {
535
536
  ).toBe("Workspace SSO is temporarily unavailable");
536
537
  });
537
538
 
538
- it("opens the app in the top window when Dispatch is inside an iframe", async () => {
539
+ it("keeps a normal iframe app inline", async () => {
539
540
  const topWindow = { location: { href: "" } } as unknown as Window;
540
- const expectedUrl = new URL("/mail", window.location.href).href;
541
541
  Object.defineProperty(window, "parent", {
542
542
  configurable: true,
543
543
  value: {},
@@ -552,15 +552,22 @@ describe("WorkspaceAppKeepAlive", () => {
552
552
  <WorkspaceAppFrame app={{ id: "mail", name: "Mail", path: "/mail" }} />,
553
553
  );
554
554
  await Promise.resolve();
555
+ await Promise.resolve();
556
+ await Promise.resolve();
555
557
  });
556
558
 
557
- expect(topWindow.location.href).toBe(expectedUrl);
558
- expect(clientState.legacyMutateAsync).not.toHaveBeenCalled();
559
- expect(container.querySelector("iframe")).toBeNull();
559
+ expect(topWindow.location.href).toBe("");
560
+ expect(clientState.legacyMutateAsync).toHaveBeenCalledWith({
561
+ app: "mail",
562
+ path: "/mail",
563
+ chrome: "minimal",
564
+ });
565
+ expect(container.querySelector("iframe")).not.toBeNull();
560
566
  });
561
567
 
562
568
  it("falls back to the embedded app when top-window navigation is blocked", async () => {
563
569
  const navigateToTopWindow = vi.fn(() => false);
570
+ clientState.inBuilderFrame = true;
564
571
  Object.defineProperty(window, "parent", {
565
572
  configurable: true,
566
573
  value: {},
@@ -594,6 +601,7 @@ describe("WorkspaceAppKeepAlive", () => {
594
601
  "/mail/emails?status=failed#latest",
595
602
  window.location.href,
596
603
  ).href;
604
+ clientState.inBuilderFrame = true;
597
605
  Object.defineProperty(window, "parent", {
598
606
  configurable: true,
599
607
  value: {},
package/src/db/index.ts CHANGED
@@ -1,9 +1,36 @@
1
- import { createGetDb } from "@agent-native/core/db";
1
+ import { createGetDb, getDbExec } from "@agent-native/core/db";
2
+ import { registerShareableResource } from "@agent-native/core/sharing";
2
3
 
3
4
  import * as schema from "./schema.js";
4
5
 
5
6
  export const getDb = createGetDb(schema);
6
7
 
8
+ registerShareableResource({
9
+ type: "workspace-app",
10
+ resourceTable: schema.workspaceApps,
11
+ sharesTable: schema.workspaceAppShares,
12
+ displayName: "Workspace app",
13
+ titleColumn: "name",
14
+ getResourcePath: (resource) =>
15
+ typeof resource?.path === "string" ? resource.path : undefined,
16
+ getDb,
17
+ allowPublic: false,
18
+ requireOrgMemberForUserShares: true,
19
+ supportsGroupShares: true,
20
+ canManageAccess: async (resource, ctx) => {
21
+ if (!ctx.userEmail || !ctx.orgId || resource?.orgId !== ctx.orgId) {
22
+ return false;
23
+ }
24
+ const result = await getDbExec().execute({
25
+ sql: `SELECT 1 FROM org_members
26
+ WHERE org_id = ? AND LOWER(email) = LOWER(?)
27
+ AND role IN ('owner', 'admin') LIMIT 1`,
28
+ args: [ctx.orgId, ctx.userEmail],
29
+ });
30
+ return result.rows.length > 0;
31
+ },
32
+ });
33
+
7
34
  export function db() {
8
35
  return getDb();
9
36
  }
package/src/db/schema.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { table, text, integer } from "@agent-native/core/db/schema";
2
2
 
3
+ // Workspace app ownership/sharing lives in core so every mounted app can
4
+ // enforce the same access record. Re-exporting these tables keeps Dispatch's
5
+ // Drizzle accessor aware of them for the generic share actions.
6
+ export { workspaceApps, workspaceAppShares } from "@agent-native/core/org";
7
+
3
8
  export const dispatchDestinations = table("dispatch_destinations", {
4
9
  id: text("id").primaryKey(),
5
10
  ownerEmail: text("owner_email").notNull(),
@@ -1,8 +1,9 @@
1
1
  // @vitest-environment happy-dom
2
- import { afterEach, describe, expect, it } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
 
4
4
  import {
5
5
  navigateToWorkspaceApp,
6
+ shouldOpenWorkspaceAppInTopWindow,
6
7
  workspaceAppDirectHref,
7
8
  workspaceAppEmbedTarget,
8
9
  } from "./workspace-apps";
@@ -66,7 +67,12 @@ describe("workspaceAppDirectHref", () => {
66
67
  });
67
68
 
68
69
  describe("navigateToWorkspaceApp", () => {
70
+ beforeEach(() => {
71
+ window.history.replaceState({}, "", "/");
72
+ });
73
+
69
74
  afterEach(() => {
75
+ window.history.replaceState({}, "", "/");
70
76
  Object.defineProperty(window, "parent", {
71
77
  configurable: true,
72
78
  value: window,
@@ -77,8 +83,18 @@ describe("navigateToWorkspaceApp", () => {
77
83
  });
78
84
  });
79
85
 
80
- it("uses the top window when Dispatch is embedded", () => {
86
+ it("keeps a normal iframe inline", () => {
87
+ Object.defineProperty(window, "parent", {
88
+ configurable: true,
89
+ value: {},
90
+ });
91
+
92
+ expect(shouldOpenWorkspaceAppInTopWindow()).toBe(false);
93
+ });
94
+
95
+ it("uses the top window when Dispatch is inside a Builder webview", () => {
81
96
  const topWindow = { location: { href: "" } } as unknown as Window;
97
+ window.history.replaceState({}, "", "/?builder.preview=interact");
82
98
  Object.defineProperty(window, "parent", {
83
99
  configurable: true,
84
100
  value: {},
@@ -88,6 +104,7 @@ describe("navigateToWorkspaceApp", () => {
88
104
  value: topWindow,
89
105
  });
90
106
 
107
+ expect(shouldOpenWorkspaceAppInTopWindow()).toBe(true);
91
108
  navigateToWorkspaceApp("/mail");
92
109
 
93
110
  expect(topWindow.location.href).toBe(
@@ -104,6 +121,7 @@ describe("navigateToWorkspaceApp", () => {
104
121
  },
105
122
  });
106
123
  const topWindow = { location } as unknown as Window;
124
+ window.history.replaceState({}, "", "/?builder.preview=interact");
107
125
  Object.defineProperty(window, "parent", {
108
126
  configurable: true,
109
127
  value: {},
@@ -15,6 +15,7 @@ export interface WorkspaceAppSummary {
15
15
  url?: string | null;
16
16
  isDispatch?: boolean;
17
17
  audience?: "internal" | "public";
18
+ visibility?: "private" | "org";
18
19
  publicPaths?: string[];
19
20
  protectedPaths?: string[];
20
21
  status?: "ready" | "pending";
@@ -255,7 +256,9 @@ export function isPendingBuilderHref(app: WorkspaceAppSummary): boolean {
255
256
 
256
257
  export function shouldOpenWorkspaceAppInTopWindow(): boolean {
257
258
  if (typeof window === "undefined") return false;
258
- return isInBuilderFrame() || window.parent !== window;
259
+ // A generic iframe is an inline host by design. Only Builder owns the
260
+ // parent navigation contract for workspace apps.
261
+ return isInBuilderFrame();
259
262
  }
260
263
 
261
264
  export function navigateToWorkspaceApp(href: string): boolean {
@@ -26,6 +26,11 @@ const mocks = vi.hoisted(() => {
26
26
  putSetting: vi.fn(async (key: string, value: unknown) => {
27
27
  settings.set(key, value);
28
28
  }),
29
+ getOrgSetting: vi.fn(async () => null),
30
+ resolveAccess: vi.fn(async () => ({
31
+ role: "viewer",
32
+ resource: {},
33
+ })),
29
34
  getDbExec: vi.fn(() => ({
30
35
  execute: vi.fn(async () => ({
31
36
  rows: state.orgRole ? [{ role: state.orgRole }] : [],
@@ -74,8 +79,18 @@ vi.mock("@agent-native/core/db", async (importOriginal) => {
74
79
  vi.mock("@agent-native/core/settings", () => ({
75
80
  getSetting: (...args: any[]) => mocks.getSetting(...args),
76
81
  putSetting: (...args: any[]) => mocks.putSetting(...args),
82
+ getOrgSetting: (...args: any[]) => mocks.getOrgSetting(...args),
77
83
  }));
78
84
 
85
+ vi.mock("@agent-native/core/sharing", async (importOriginal) => {
86
+ const actual =
87
+ await importOriginal<typeof import("@agent-native/core/sharing")>();
88
+ return {
89
+ ...actual,
90
+ resolveAccess: (...args: any[]) => mocks.resolveAccess(...args),
91
+ };
92
+ });
93
+
79
94
  vi.mock("@agent-native/core/server", async (importOriginal) => {
80
95
  const actual =
81
96
  await importOriginal<typeof import("@agent-native/core/server")>();
@@ -105,6 +120,14 @@ afterEach(() => {
105
120
  vi.clearAllMocks();
106
121
  mocks.settings.clear();
107
122
  mocks.state.orgRole = "admin";
123
+ mocks.getDbExec.mockReset();
124
+ mocks.getDbExec.mockImplementation(() => ({
125
+ execute: vi.fn(async () => ({
126
+ rows: mocks.state.orgRole ? [{ role: mocks.state.orgRole }] : [],
127
+ })),
128
+ }));
129
+ mocks.resolveAccess.mockReset();
130
+ mocks.resolveAccess.mockResolvedValue({ role: "viewer", resource: {} });
108
131
  mocks.resolveBuilderCredentialsDetailed.mockResolvedValue({
109
132
  privateKey: null,
110
133
  publicKey: null,
@@ -345,6 +368,103 @@ describe("listWorkspaceApps", () => {
345
368
  expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
346
369
  });
347
370
 
371
+ it("does not create or expose apps when the org visibility setting cannot be read", async () => {
372
+ stubManifest([
373
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
374
+ { id: "private-app", name: "Private app", path: "/private-app" },
375
+ ]);
376
+ mocks.getOrgSetting.mockRejectedValueOnce(
377
+ new Error("settings store unavailable"),
378
+ );
379
+
380
+ await expect(
381
+ runWithRequestContext(
382
+ { userEmail: "dev@example.test", orgId: "org-123" },
383
+ () => listWorkspaceApps({ includeAgentCards: false }),
384
+ ),
385
+ ).rejects.toThrow("settings store unavailable");
386
+ });
387
+
388
+ it("fails closed when the workspace-app access schema is unavailable", async () => {
389
+ stubManifest([
390
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
391
+ { id: "private-app", name: "Private app", path: "/private-app" },
392
+ ]);
393
+ mocks.resolveAccess.mockRejectedValueOnce(
394
+ new Error("no such table: workspace_app_shares"),
395
+ );
396
+
397
+ const apps = await runWithRequestContext(
398
+ { userEmail: "dev@example.test", orgId: "org-123" },
399
+ () => listWorkspaceApps({ includeAgentCards: false }),
400
+ );
401
+
402
+ expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
403
+ });
404
+
405
+ it("does not expose the workspace app registry without an authenticated user", async () => {
406
+ stubNoPendingContext();
407
+ stubManifest([
408
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
409
+ { id: "private-app", name: "Private app", path: "/private-app" },
410
+ ]);
411
+
412
+ const apps = await runWithRequestContext({ orgId: "org-123" }, () =>
413
+ listWorkspaceApps({ includeAgentCards: false }),
414
+ );
415
+
416
+ expect(apps).toEqual([]);
417
+ expect(mocks.resolveAccess).not.toHaveBeenCalled();
418
+ });
419
+
420
+ it("uses the organization default only for apps with trusted creation metadata", async () => {
421
+ stubNoPendingContext();
422
+ stubManifest([
423
+ {
424
+ id: "legacy-app",
425
+ name: "Legacy app",
426
+ path: "/legacy-app",
427
+ },
428
+ {
429
+ id: "new-app",
430
+ name: "New app",
431
+ path: "/new-app",
432
+ createdBy: "creator@example.test",
433
+ },
434
+ ]);
435
+ mocks.getOrgSetting.mockResolvedValueOnce({ visibility: "private" });
436
+ const execute = vi.fn(async (statement: unknown) => {
437
+ const sql =
438
+ typeof statement === "string"
439
+ ? statement
440
+ : String((statement as { sql?: unknown })?.sql ?? "");
441
+ if (sql.includes("SELECT id, owner_email, org_id, visibility")) {
442
+ return { rows: [], rowsAffected: 0 };
443
+ }
444
+ return { rows: [], rowsAffected: 1 };
445
+ });
446
+ mocks.getDbExec.mockReturnValue({ execute });
447
+
448
+ const apps = await runWithRequestContext(
449
+ { userEmail: "dev@example.test", orgId: "org-123" },
450
+ () => listWorkspaceApps({ includeAgentCards: false }),
451
+ );
452
+
453
+ expect(apps.find((app) => app.id === "legacy-app")?.visibility).toBe("org");
454
+ expect(apps.find((app) => app.id === "new-app")?.visibility).toBe(
455
+ "private",
456
+ );
457
+ const inserts = execute.mock.calls.filter(([statement]) =>
458
+ String((statement as { sql?: unknown })?.sql ?? "").includes(
459
+ "INSERT INTO workspace_apps",
460
+ ),
461
+ );
462
+ expect(inserts.map(([statement]) => (statement as any).args?.[3])).toEqual([
463
+ "org",
464
+ "private",
465
+ ]);
466
+ });
467
+
348
468
  it("projects exact custom SSO eligibility without exposing registry details", async () => {
349
469
  stubNoPendingContext();
350
470
  stubManifest([