@agent-native/dispatch 0.36.0-nightly-20260909183543 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/actions/apply-dream-proposal.js +2 -1
  2. package/dist/actions/apply-dream-proposal.js.map +1 -1
  3. package/dist/actions/approve-dispatch-change.js +2 -0
  4. package/dist/actions/approve-dispatch-change.js.map +1 -1
  5. package/dist/actions/create-pylon-ticket.js +2 -0
  6. package/dist/actions/create-pylon-ticket.js.map +1 -1
  7. package/dist/actions/create-workspace-resource-grant.js +2 -0
  8. package/dist/actions/create-workspace-resource-grant.js.map +1 -1
  9. package/dist/actions/create-workspace-resource.js +2 -0
  10. package/dist/actions/create-workspace-resource.js.map +1 -1
  11. package/dist/actions/delete-destination.js +2 -0
  12. package/dist/actions/delete-destination.js.map +1 -1
  13. package/dist/actions/delete-workspace-resource.js +2 -0
  14. package/dist/actions/delete-workspace-resource.js.map +1 -1
  15. package/dist/actions/ensure-dream-job.js +2 -0
  16. package/dist/actions/ensure-dream-job.js.map +1 -1
  17. package/dist/actions/grant-workspace-resources-to-app.js +2 -0
  18. package/dist/actions/grant-workspace-resources-to-app.js.map +1 -1
  19. package/dist/actions/import-agent-pack.d.ts.map +1 -1
  20. package/dist/actions/import-agent-pack.js +2 -0
  21. package/dist/actions/import-agent-pack.js.map +1 -1
  22. package/dist/actions/import-agent.d.ts.map +1 -1
  23. package/dist/actions/import-agent.js +2 -0
  24. package/dist/actions/import-agent.js.map +1 -1
  25. package/dist/actions/reject-dispatch-change.js +2 -0
  26. package/dist/actions/reject-dispatch-change.js.map +1 -1
  27. package/dist/actions/reject-dream-proposal.js +2 -1
  28. package/dist/actions/reject-dream-proposal.js.map +1 -1
  29. package/dist/actions/restore-starter-workspace-resources.js +2 -0
  30. package/dist/actions/restore-starter-workspace-resources.js.map +1 -1
  31. package/dist/actions/revoke-workspace-resource-grant.js +2 -0
  32. package/dist/actions/revoke-workspace-resource-grant.js.map +1 -1
  33. package/dist/actions/send-platform-message.js +2 -0
  34. package/dist/actions/send-platform-message.js.map +1 -1
  35. package/dist/actions/set-dispatch-approval-policy.js +2 -0
  36. package/dist/actions/set-dispatch-approval-policy.js.map +1 -1
  37. package/dist/actions/set-dream-settings.js +2 -0
  38. package/dist/actions/set-dream-settings.js.map +1 -1
  39. package/dist/actions/update-workspace-resource.js +2 -0
  40. package/dist/actions/update-workspace-resource.js.map +1 -1
  41. package/dist/actions/upsert-destination.d.ts +12 -12
  42. package/dist/actions/upsert-destination.js +2 -0
  43. package/dist/actions/upsert-destination.js.map +1 -1
  44. package/dist/components/dispatch-access.d.ts +1 -1
  45. package/dist/components/dispatch-access.d.ts.map +1 -1
  46. package/dist/components/dispatch-access.js +0 -9
  47. package/dist/components/dispatch-access.js.map +1 -1
  48. package/dist/server/lib/app-creation-store.js +1 -1
  49. package/dist/server/lib/app-creation-store.js.map +1 -1
  50. package/dist/server/lib/app-roles.d.ts +7 -0
  51. package/dist/server/lib/app-roles.d.ts.map +1 -0
  52. package/dist/server/lib/app-roles.js +45 -0
  53. package/dist/server/lib/app-roles.js.map +1 -0
  54. package/dist/server/lib/dispatch-store.d.ts.map +1 -1
  55. package/dist/server/lib/dispatch-store.js +1 -1
  56. package/dist/server/lib/dispatch-store.js.map +1 -1
  57. package/dist/server/lib/dreams-store.d.ts +4 -0
  58. package/dist/server/lib/dreams-store.d.ts.map +1 -1
  59. package/dist/server/lib/dreams-store.js +22 -1
  60. package/dist/server/lib/dreams-store.js.map +1 -1
  61. package/package.json +4 -4
  62. package/src/actions/apply-dream-proposal.ts +5 -1
  63. package/src/actions/approve-dispatch-change.ts +2 -0
  64. package/src/actions/create-pylon-ticket.ts +2 -0
  65. package/src/actions/create-workspace-resource-grant.ts +2 -0
  66. package/src/actions/create-workspace-resource.ts +2 -0
  67. package/src/actions/delete-destination.ts +2 -0
  68. package/src/actions/delete-workspace-resource.ts +2 -0
  69. package/src/actions/dispatch-admin-authorization.spec.ts +114 -0
  70. package/src/actions/ensure-dream-job.ts +2 -0
  71. package/src/actions/grant-workspace-resources-to-app.ts +2 -0
  72. package/src/actions/import-agent-pack.ts +2 -0
  73. package/src/actions/import-agent.ts +2 -0
  74. package/src/actions/reject-dispatch-change.ts +2 -0
  75. package/src/actions/reject-dream-proposal.ts +5 -1
  76. package/src/actions/restore-starter-workspace-resources.ts +2 -0
  77. package/src/actions/revoke-workspace-resource-grant.ts +2 -0
  78. package/src/actions/send-platform-message.spec.ts +4 -0
  79. package/src/actions/send-platform-message.ts +2 -0
  80. package/src/actions/set-dispatch-approval-policy.ts +2 -0
  81. package/src/actions/set-dream-settings.spec.ts +5 -0
  82. package/src/actions/set-dream-settings.ts +2 -0
  83. package/src/actions/update-workspace-resource.ts +2 -0
  84. package/src/actions/upsert-destination.ts +2 -0
  85. package/src/components/dispatch-access.spec.tsx +1 -63
  86. package/src/components/dispatch-access.tsx +0 -8
  87. package/src/server/lib/app-creation-store.spec.ts +21 -22
  88. package/src/server/lib/app-creation-store.ts +1 -1
  89. package/src/server/lib/app-roles.spec.ts +120 -0
  90. package/src/server/lib/app-roles.ts +67 -0
  91. package/src/server/lib/approval-fencing.spec.ts +39 -0
  92. package/src/server/lib/dispatch-store.ts +4 -1
  93. package/src/server/lib/dreams-store.spec.ts +78 -0
  94. package/src/server/lib/dreams-store.ts +37 -1
@@ -1450,7 +1450,7 @@ async function filterWorkspaceAppsByAccess(
1450
1450
  }
1451
1451
  if (app.isDispatch) {
1452
1452
  if (
1453
- await isWorkspaceAppAccessAllowed(app.id, {
1453
+ await isWorkspaceAppAccessAllowed("dispatch", {
1454
1454
  email: userEmail,
1455
1455
  orgId,
1456
1456
  })
@@ -0,0 +1,120 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ assertAny: vi.fn(),
5
+ getRequestOrgId: vi.fn(),
6
+ getRequestUserEmail: vi.fn(),
7
+ isStandaloneDispatchRuntime: vi.fn(),
8
+ validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(),
9
+ }));
10
+
11
+ vi.mock("@agent-native/core/org", () => ({
12
+ defineAppRoles: () => ({ assertAny: mocks.assertAny }),
13
+ isMissingOrganizationTableError: (error: unknown) =>
14
+ /(?:organizations|org_members).*does not exist/i.test(String(error)),
15
+ isStandaloneDispatchRuntime: mocks.isStandaloneDispatchRuntime,
16
+ validateFederatedOrganizationMembershipForCurrentRequest:
17
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
18
+ }));
19
+
20
+ vi.mock("@agent-native/core/server", () => ({
21
+ getRequestOrgId: mocks.getRequestOrgId,
22
+ getRequestUserEmail: mocks.getRequestUserEmail,
23
+ }));
24
+
25
+ import { ForbiddenError } from "@agent-native/core/sharing";
26
+
27
+ import { authorizeDispatchAdmin } from "./app-roles.js";
28
+
29
+ const context = {
30
+ caller: "http" as const,
31
+ orgId: "org-1",
32
+ userEmail: "member@example.test",
33
+ };
34
+
35
+ describe("authorizeDispatchAdmin", () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
39
+ { active: true, role: "member" },
40
+ );
41
+ mocks.assertAny.mockResolvedValue("admin");
42
+ mocks.getRequestOrgId.mockReturnValue(undefined);
43
+ mocks.getRequestUserEmail.mockReturnValue(undefined);
44
+ mocks.isStandaloneDispatchRuntime.mockReturnValue(false);
45
+ });
46
+
47
+ it("denies an organization member without the Dispatch admin role", async () => {
48
+ mocks.assertAny.mockRejectedValue(
49
+ new ForbiddenError("Requires dispatch role admin"),
50
+ );
51
+
52
+ await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow(
53
+ "Requires dispatch role admin",
54
+ );
55
+ expect(
56
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
57
+ ).toHaveBeenCalledWith({
58
+ orgId: "org-1",
59
+ email: "member@example.test",
60
+ });
61
+ expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], {
62
+ orgId: "org-1",
63
+ userEmail: "member@example.test",
64
+ });
65
+ });
66
+
67
+ it("allows an organization admin without an app-role assignment", async () => {
68
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
69
+ { active: true, role: "admin" },
70
+ );
71
+
72
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
73
+ expect(mocks.assertAny).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("denies a stale linked organization admin", async () => {
77
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
78
+ { active: false, role: null },
79
+ );
80
+
81
+ await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow(
82
+ "active organization membership",
83
+ );
84
+ expect(mocks.assertAny).not.toHaveBeenCalled();
85
+ });
86
+
87
+ it("allows standalone administration when the org schema is absent", async () => {
88
+ mocks.isStandaloneDispatchRuntime.mockReturnValue(true);
89
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockRejectedValue(
90
+ new Error('relation "org_members" does not exist'),
91
+ );
92
+
93
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
94
+ expect(mocks.assertAny).not.toHaveBeenCalled();
95
+ });
96
+
97
+ it("allows a member with the Dispatch admin role", async () => {
98
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
99
+ expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], {
100
+ orgId: "org-1",
101
+ userEmail: "member@example.test",
102
+ });
103
+ });
104
+
105
+ it("allows authenticated personal-mode administration", async () => {
106
+ await expect(
107
+ authorizeDispatchAdmin({}, { ...context, orgId: null }),
108
+ ).resolves.toBeUndefined();
109
+ expect(
110
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
111
+ ).not.toHaveBeenCalled();
112
+ expect(mocks.assertAny).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("denies an unauthenticated caller", async () => {
116
+ await expect(
117
+ authorizeDispatchAdmin({}, { ...context, userEmail: undefined }),
118
+ ).rejects.toBeInstanceOf(ForbiddenError);
119
+ });
120
+ });
@@ -0,0 +1,67 @@
1
+ import type { ActionRunContext } from "@agent-native/core/action";
2
+ import {
3
+ defineAppRoles,
4
+ isMissingOrganizationTableError,
5
+ isStandaloneDispatchRuntime,
6
+ type AppRoles,
7
+ validateFederatedOrganizationMembershipForCurrentRequest,
8
+ } from "@agent-native/core/org";
9
+ import {
10
+ getRequestOrgId,
11
+ getRequestUserEmail,
12
+ } from "@agent-native/core/server";
13
+ import { ForbiddenError } from "@agent-native/core/sharing";
14
+
15
+ import { dispatchAccessDescriptor } from "../../shared/app-roles.js";
16
+
17
+ let dispatchAccess: AppRoles<"admin"> | undefined;
18
+
19
+ function getDispatchAccess(): AppRoles<"admin"> {
20
+ return (dispatchAccess ??= defineAppRoles(dispatchAccessDescriptor));
21
+ }
22
+
23
+ /**
24
+ * Keep the Dispatch shell open to every signed-in member while protecting
25
+ * workspace-wide administration operations for org or Dispatch admins.
26
+ */
27
+ export async function authorizeDispatchAdmin(
28
+ _args: unknown,
29
+ ctx?: ActionRunContext,
30
+ ): Promise<void> {
31
+ const email =
32
+ ctx?.userEmail !== undefined ? ctx.userEmail : getRequestUserEmail();
33
+ const orgId = ctx?.orgId !== undefined ? ctx.orgId : getRequestOrgId();
34
+ if (!email?.trim()) {
35
+ throw new ForbiddenError(
36
+ "Dispatch administration requires an authenticated user.",
37
+ );
38
+ }
39
+ if (!orgId?.trim()) return;
40
+ let membership;
41
+ try {
42
+ membership = await validateFederatedOrganizationMembershipForCurrentRequest(
43
+ {
44
+ orgId,
45
+ email,
46
+ },
47
+ );
48
+ } catch (error) {
49
+ if (
50
+ isMissingOrganizationTableError(error) &&
51
+ isStandaloneDispatchRuntime()
52
+ ) {
53
+ return;
54
+ }
55
+ throw error;
56
+ }
57
+ if (!membership.active) {
58
+ throw new ForbiddenError(
59
+ "Dispatch administration requires active organization membership.",
60
+ );
61
+ }
62
+ if (membership.role === "owner" || membership.role === "admin") return;
63
+ await getDispatchAccess().assertAny(["admin"], {
64
+ userEmail: email,
65
+ orgId,
66
+ });
67
+ }
@@ -340,6 +340,45 @@ describe("dispatch approval request status fencing", () => {
340
340
  });
341
341
  });
342
342
 
343
+ it("does not let the same email approve a request from another organization", async () => {
344
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
345
+ await Promise.all([
346
+ import("@agent-native/core/server"),
347
+ import("@agent-native/core/db"),
348
+ import("./dispatch-store.js"),
349
+ ]);
350
+ const exec = getDbExec();
351
+
352
+ const requestId = await runWithRequestContext(
353
+ { userEmail: ownerEmail, orgId },
354
+ async () => {
355
+ const created = await dispatchStore.createApprovalRequest({
356
+ changeType: "approval-policy.update",
357
+ targetType: "dispatch-settings",
358
+ targetId: "dispatch-approval-policy",
359
+ summary: "Keep this request in its organization",
360
+ payload: { enabled: true, approverEmails: [] },
361
+ });
362
+ return (created as any).id as string;
363
+ },
364
+ );
365
+
366
+ await runWithRequestContext(
367
+ { userEmail: ownerEmail, orgId: otherOrgId },
368
+ async () => {
369
+ await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
370
+ "Approval request not found",
371
+ );
372
+ },
373
+ );
374
+
375
+ const rows = await exec.execute({
376
+ sql: "SELECT status FROM dispatch_approval_requests WHERE id = ?",
377
+ args: [requestId],
378
+ });
379
+ expect(rows.rows[0]).toMatchObject({ status: "pending" });
380
+ });
381
+
343
382
  it("does not let a caller from a different tenant reject another tenant's request", async () => {
344
383
  const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
345
384
  await Promise.all([
@@ -157,7 +157,10 @@ function ctxScope<T extends { ownerEmail: any; orgId: any }>(
157
157
  if (!ctx.orgId) {
158
158
  return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId));
159
159
  }
160
- return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId));
160
+ return or(
161
+ and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)),
162
+ eq(table.orgId, ctx.orgId),
163
+ );
161
164
  }
162
165
 
163
166
  function id() {
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
4
4
  getDb: vi.fn(),
5
5
  currentOwnerEmail: vi.fn(() => "owner@example.test"),
6
6
  currentOrgId: vi.fn(() => null),
7
+ authorizeDispatchAdmin: vi.fn(),
7
8
  getApprovalPolicy: vi.fn(),
8
9
  createApprovalRequest: vi.fn(),
9
10
  recordAudit: vi.fn(),
@@ -42,6 +43,10 @@ vi.mock("./dispatch-store.js", () => ({
42
43
  recordAudit: mocks.recordAudit,
43
44
  }));
44
45
 
46
+ vi.mock("./app-roles.js", () => ({
47
+ authorizeDispatchAdmin: mocks.authorizeDispatchAdmin,
48
+ }));
49
+
45
50
  vi.mock("./thread-debug-store.js", () => ({
46
51
  searchAgentThreads: mocks.searchAgentThreads,
47
52
  getAgentThreadDebug: mocks.getAgentThreadDebug,
@@ -72,6 +77,7 @@ import { schema } from "../../db/index.js";
72
77
  import {
73
78
  applyApprovedDreamProposal,
74
79
  applyDreamProposal,
80
+ authorizeDreamProposalMutation,
75
81
  buildProposalInputs,
76
82
  ensureDreamJob,
77
83
  getDreamSettings,
@@ -301,6 +307,78 @@ afterEach(() => {
301
307
  vi.clearAllMocks();
302
308
  });
303
309
 
310
+ describe("authorizeDreamProposalMutation", () => {
311
+ it("allows an owner to change a personal-memory proposal", async () => {
312
+ mocks.getDb.mockReturnValue(createDbMock(pendingProposal()));
313
+
314
+ await expect(
315
+ authorizeDreamProposalMutation(
316
+ { id: "proposal-1" },
317
+ {
318
+ caller: "http",
319
+ orgId: null,
320
+ userEmail: "owner@example.test",
321
+ },
322
+ ),
323
+ ).resolves.toBeUndefined();
324
+ expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it("requires Dispatch admin authorization for shared proposals", async () => {
328
+ mocks.getDb.mockReturnValue(
329
+ createDbMock(
330
+ pendingProposal({
331
+ orgId: "org-1",
332
+ targetType: "shared-learnings",
333
+ targetPath: "LEARNINGS.md",
334
+ }),
335
+ ),
336
+ );
337
+ mocks.authorizeDispatchAdmin.mockResolvedValue(undefined);
338
+
339
+ await expect(
340
+ authorizeDreamProposalMutation(
341
+ { id: "proposal-1" },
342
+ {
343
+ caller: "http",
344
+ orgId: "org-1",
345
+ userEmail: "member@example.test",
346
+ },
347
+ ),
348
+ ).resolves.toBeUndefined();
349
+ expect(mocks.authorizeDispatchAdmin).toHaveBeenCalledWith(
350
+ { id: "proposal-1" },
351
+ expect.objectContaining({
352
+ orgId: "org-1",
353
+ userEmail: "member@example.test",
354
+ }),
355
+ );
356
+ });
357
+
358
+ it("does not let an organization member change another user's personal proposal", async () => {
359
+ mocks.getDb.mockReturnValue(
360
+ createDbMock(
361
+ pendingProposal({
362
+ ownerEmail: "other@example.test",
363
+ orgId: "org-1",
364
+ }),
365
+ ),
366
+ );
367
+
368
+ await expect(
369
+ authorizeDreamProposalMutation(
370
+ { id: "proposal-1" },
371
+ {
372
+ caller: "http",
373
+ orgId: "org-1",
374
+ userEmail: "member@example.test",
375
+ },
376
+ ),
377
+ ).rejects.toThrow("can only be changed by their owner");
378
+ expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled();
379
+ });
380
+ });
381
+
304
382
  describe("listDreamCandidates", () => {
305
383
  it("scores grounded thread signals and keeps per-thread debug errors isolated", async () => {
306
384
  mocks.searchAgentThreads.mockResolvedValue({
@@ -1,5 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
 
3
+ import type { ActionRunContext } from "@agent-native/core/action";
3
4
  import {
4
5
  and,
5
6
  desc,
@@ -20,8 +21,10 @@ import {
20
21
  putOrgSetting,
21
22
  putUserSetting,
22
23
  } from "@agent-native/core/settings";
24
+ import { ForbiddenError } from "@agent-native/core/sharing";
23
25
 
24
26
  import { getDb, schema } from "../../db/index.js";
27
+ import { authorizeDispatchAdmin } from "./app-roles.js";
25
28
  import {
26
29
  createApprovalRequest,
27
30
  currentOrgId,
@@ -444,7 +447,10 @@ function scopeFor<T extends { ownerEmail: any; orgId: any }>(
444
447
  if (!ctx.orgId) {
445
448
  return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId));
446
449
  }
447
- return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId));
450
+ return or(
451
+ and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)),
452
+ eq(table.orgId, ctx.orgId),
453
+ );
448
454
  }
449
455
 
450
456
  function safeJson(value: unknown): string {
@@ -2394,6 +2400,36 @@ async function getProposalRow(
2394
2400
  return row ?? null;
2395
2401
  }
2396
2402
 
2403
+ export async function authorizeDreamProposalMutation(
2404
+ args: { id: string },
2405
+ ctx?: ActionRunContext,
2406
+ ): Promise<void> {
2407
+ const ownerEmail =
2408
+ ctx?.userEmail !== undefined ? ctx.userEmail : currentOwnerEmail();
2409
+ const orgId = ctx?.orgId !== undefined ? ctx.orgId : currentOrgId();
2410
+ const proposal = await getProposalRow(args.id, {
2411
+ ownerEmail,
2412
+ orgId: orgId?.trim() || null,
2413
+ });
2414
+ if (!proposal) {
2415
+ throw new ForbiddenError("Dream proposal not found");
2416
+ }
2417
+
2418
+ if (proposal.targetType === "personal-memory") {
2419
+ if (
2420
+ proposal.ownerEmail.trim().toLowerCase() !==
2421
+ ownerEmail.trim().toLowerCase()
2422
+ ) {
2423
+ throw new ForbiddenError(
2424
+ "Personal memory proposals can only be changed by their owner",
2425
+ );
2426
+ }
2427
+ return;
2428
+ }
2429
+
2430
+ await authorizeDispatchAdmin(args, ctx);
2431
+ }
2432
+
2397
2433
  export async function createDreamReport(input: {
2398
2434
  sourceId?: string;
2399
2435
  sourceIds?: string[];