@agent-native/dispatch 0.29.5 → 0.30.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.
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.0",
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": {
@@ -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.163.0"
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: () => ({
@@ -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}
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(),
@@ -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";
@@ -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([