diff --git a/.changeset/restrict-dispatch-org-access.md b/.changeset/restrict-dispatch-org-access.md
index 5513ad037ce..7fbab936fa3 100644
--- a/.changeset/restrict-dispatch-org-access.md
+++ b/.changeset/restrict-dispatch-org-access.md
@@ -3,4 +3,4 @@
"@agent-native/dispatch": patch
---
-Restrict organization-scoped Dispatch access to owners and admins.
+Restore Dispatch access for all authenticated organization members.
diff --git a/packages/core/src/client/org/OrgSwitcher.spec.tsx b/packages/core/src/client/org/OrgSwitcher.spec.tsx
index 9fb559922e4..3603c5b9c12 100644
--- a/packages/core/src/client/org/OrgSwitcher.spec.tsx
+++ b/packages/core/src/client/org/OrgSwitcher.spec.tsx
@@ -599,7 +599,7 @@ describe("OrgSwitcher", () => {
).not.toBeNull();
});
- it.each(["owner", "admin"] as const)(
+ it.each(["owner", "admin", "member"] as const)(
"shows Dispatch to organization %s members",
(role) => {
mocks.appLinks.mockReturnValue({
@@ -658,7 +658,57 @@ describe("OrgSwitcher", () => {
},
);
- it("hides Dispatch and its all-apps link from organization members", () => {
+ it("does not synthesize Dispatch after an org registry revokes access", () => {
+ mocks.appLinks.mockReturnValue({
+ apps: [
+ {
+ id: "analytics",
+ name: "Analytics",
+ href: "/analytics",
+ isDispatch: false,
+ status: "ready",
+ },
+ ],
+ dispatchAllAppsHref: "/dispatch/apps",
+ dispatchHref: "/dispatch/overview",
+ isLoading: false,
+ isWorkspace: true,
+ });
+ mocks.useOrg.mockReturnValue({
+ data: {
+ email: "member@example.com",
+ orgId: "org-1",
+ orgName: "Acme",
+ role: "member",
+ orgs: [{ orgId: "org-1", orgName: "Acme", role: "member" }],
+ pendingInvitations: [],
+ domainMatches: [],
+ },
+ isLoading: false,
+ });
+
+ render();
+ act(() => {
+ container.querySelector("button")!.click();
+ });
+ const appsButton = Array.from(
+ document.body.querySelectorAll("button"),
+ ).find((button) => button.textContent?.trim().startsWith("Apps"));
+ expect(appsButton).not.toBeNull();
+
+ act(() => {
+ appsButton!.click();
+ });
+
+ expect(
+ document.body.querySelector(
+ 'a[href="/dispatch/overview"]',
+ ),
+ ).toBeNull();
+ expect(document.body.textContent).not.toContain("more in Dispatch");
+ });
+
+ it("shows Dispatch and its all-apps link to organization members", () => {
mocks.appLinks.mockReturnValue({
apps: [
{
@@ -718,10 +768,10 @@ describe("OrgSwitcher", () => {
document.body.querySelector(
'a[href="/dispatch/overview"]',
),
- ).toBeNull();
+ ).not.toBeNull();
expect(
document.body.querySelector('a[href="/analytics"]'),
).not.toBeNull();
- expect(document.body.textContent).not.toContain("more in Dispatch");
+ expect(document.body.textContent).toContain("more in Dispatch");
});
});
diff --git a/packages/core/src/client/org/OrgSwitcher.tsx b/packages/core/src/client/org/OrgSwitcher.tsx
index d6bc5668e7f..58c936cf9e9 100644
--- a/packages/core/src/client/org/OrgSwitcher.tsx
+++ b/packages/core/src/client/org/OrgSwitcher.tsx
@@ -44,7 +44,6 @@ import { useState, type ReactNode } from "react";
import { Link, useNavigate } from "react-router";
import { setBrowserDemoModeEnabled } from "../../demo/browser-state.js";
-import { canManageOrg } from "../../org/permissions.js";
import { shouldOfferWorkspace } from "../../org/workspace-url.js";
import { useT } from "../i18n.js";
import { signOut } from "../sign-out.js";
@@ -221,46 +220,47 @@ function AppMenuLink({
function AppsSubmenu({
apps,
isLoading,
+ isWorkspace,
dispatchHref,
dispatchAllAppsHref,
- canAccessDispatch,
currentAppId,
onNavigate,
}: {
apps: OrgSwitcherAppLink[];
isLoading: boolean;
+ isWorkspace: boolean;
dispatchHref: string;
dispatchAllAppsHref: string;
- canAccessDispatch: boolean;
currentAppId?: string;
onNavigate: () => void;
}) {
const appsForMenu = currentAppId
? apps.filter((app) => app.id !== currentAppId)
: apps;
- const accessibleAppsForMenu = canAccessDispatch
- ? appsForMenu
- : appsForMenu.filter((app) => !app.isDispatch);
- const { links, overflowCount } = visibleOrgAppLinks(accessibleAppsForMenu);
+ const { links, overflowCount } = visibleOrgAppLinks(appsForMenu);
const visibleDispatchApp = links.find((app) => app.isDispatch);
- const dispatchApp =
- !canAccessDispatch || currentAppId === "dispatch"
- ? null
- : (visibleDispatchApp ??
- ({
+ const fallbackDispatchApp =
+ !isWorkspace || isLoading
+ ? {
id: "dispatch",
name: "Dispatch",
href: dispatchHref,
isDispatch: true,
- status: "ready",
- } satisfies OrgSwitcherAppLink));
+ status: "ready" as const,
+ }
+ : null;
+ const dispatchApp =
+ currentAppId === "dispatch"
+ ? null
+ : (visibleDispatchApp ?? fallbackDispatchApp);
const visibleNonDispatch = links
.filter((app) => !app.isDispatch)
.slice(0, dispatchApp ? undefined : ORG_SWITCHER_MAX_APP_LINKS);
const shownCount = (dispatchApp ? 1 : 0) + visibleNonDispatch.length;
- const remainingCount = canAccessDispatch
- ? Math.max(overflowCount, accessibleAppsForMenu.length - shownCount)
- : 0;
+ const remainingCount = Math.max(
+ overflowCount,
+ appsForMenu.length - shownCount,
+ );
return (
@@ -272,7 +272,7 @@ function AppsSubmenu({
{isLoading ? (
) : (
- accessibleAppsForMenu.length
+ appsForMenu.length
)}
@@ -418,7 +418,6 @@ export function OrgSwitcher({
const canInvite =
!!org.orgId && (org.role === "owner" || org.role === "admin");
- const canAccessDispatch = !org.orgId || canManageOrg(org.role);
const personalLabel = session?.name || personalLabelFromEmail(org.email);
const inOrg = !!org.orgId;
@@ -716,9 +715,9 @@ export function OrgSwitcher({
setOpen(false)}
/>
diff --git a/packages/core/src/org/federation.spec.ts b/packages/core/src/org/federation.spec.ts
index 89a9dacce4f..9c605c63c72 100644
--- a/packages/core/src/org/federation.spec.ts
+++ b/packages/core/src/org/federation.spec.ts
@@ -45,6 +45,7 @@ const {
syncOrganizationToIdentityHub,
updateFederatedOrganizationMemberRole,
validateFederatedOrganizationMembership,
+ validateFederatedOrganizationMembershipForCurrentRequest,
} = await import("./federation.js");
const identity = {
@@ -516,6 +517,35 @@ describe("cross-app organization federation", () => {
);
});
+ it("validates a local organization for CLI callers without a request origin", async () => {
+ executeMock.mockImplementation(async (input) => {
+ const sql = (typeof input === "string" ? input : input.sql).trim();
+ if (/SELECT name, identity_authority/i.test(sql)) {
+ return {
+ rows: [
+ {
+ name: "Example Org",
+ identity_authority: null,
+ identity_id: null,
+ },
+ ],
+ };
+ }
+ if (/SELECT role, federation_removal_pending_at/i.test(sql)) {
+ return { rows: [{ role: "admin" }] };
+ }
+ throw new Error(`unexpected SQL in test: ${sql}`);
+ });
+
+ await expect(
+ validateFederatedOrganizationMembershipForCurrentRequest({
+ orgId: "local-org-1",
+ email: "admin@example.test",
+ }),
+ ).resolves.toEqual({ active: true, role: "admin" });
+ expect(getOriginMock).not.toHaveBeenCalled();
+ });
+
it("refreshes a satellite membership role from the authority", async () => {
executeMock.mockImplementation(async (input) => {
const sql = (typeof input === "string" ? input : input.sql).trim();
diff --git a/packages/core/src/org/federation.ts b/packages/core/src/org/federation.ts
index e114bf3bfa9..05b1ebafcb3 100644
--- a/packages/core/src/org/federation.ts
+++ b/packages/core/src/org/federation.ts
@@ -58,13 +58,9 @@ export type FederatedMembershipValidation =
| { active: true; role: OrgRole }
| { active: false; role: null };
-function requestEventFromContext(): H3Event {
+function requestEventFromContext(): H3Event | undefined {
const requestOrigin = getRequestContext()?.requestOrigin;
- if (!requestOrigin) {
- throw new Error(
- "Federated membership validation requires a request origin.",
- );
- }
+ if (!requestOrigin) return undefined;
let url: URL;
try {
url = new URL(requestOrigin);
@@ -422,7 +418,7 @@ export async function revokeFederatedOrganizationMember(
* them while an unavailable authority fails closed.
*/
export async function validateFederatedOrganizationMembership(
- event: H3Event,
+ event: H3Event | undefined,
input: { orgId: string; email: string },
): Promise {
const email = input.email.trim().toLowerCase();
@@ -458,7 +454,7 @@ export async function validateFederatedOrganizationMembership(
throw new Error("Organization has an invalid identity mapping.");
}
- const currentOrigin = normalizeAuthority(getOrigin(event));
+ const currentOrigin = event ? normalizeAuthority(getOrigin(event)) : null;
if (currentOrigin === identityAuthority) {
return { active: true, role: localRole };
}
@@ -471,6 +467,11 @@ export async function validateFederatedOrganizationMembership(
"Cross-app organization federation rollout is unavailable.",
);
}
+ if (!event) {
+ throw new Error(
+ "Federated membership validation requires a request origin.",
+ );
+ }
const hub = resolveIdentityHubUrl(event);
const authority = normalizeAuthority(hub ?? "");
if (!authority || authority !== identityAuthority) {
diff --git a/packages/core/src/org/index.ts b/packages/core/src/org/index.ts
index 9df59cc0a94..09c1a4ca5a7 100644
--- a/packages/core/src/org/index.ts
+++ b/packages/core/src/org/index.ts
@@ -44,7 +44,11 @@ export { autoJoinDomainMatchingOrgs } from "./auto-join-domain.js";
export type { AutoJoinDomainResult } from "./auto-join-domain.js";
export { setActiveOrgId } from "./active-org.js";
export { invalidateMemberOrgCaches } from "./request-org-cache.js";
-export { isWorkspaceAppAccessAllowed } from "./workspace-app-access.js";
+export { isMissingOrganizationTableError } from "./membership.js";
+export {
+ isStandaloneDispatchRuntime,
+ isWorkspaceAppAccessAllowed,
+} from "./workspace-app-access.js";
export {
defineAppRoles,
diff --git a/packages/core/src/org/membership.spec.ts b/packages/core/src/org/membership.spec.ts
index 96d8a8a2f30..6f29f205011 100644
--- a/packages/core/src/org/membership.spec.ts
+++ b/packages/core/src/org/membership.spec.ts
@@ -97,4 +97,12 @@ describe("isMissingOrganizationTableError", () => {
expect(isMissingOrganizationTableError(error)).toBe(true);
});
+
+ it("recognizes a missing org-members relation", () => {
+ expect(
+ isMissingOrganizationTableError(
+ new Error('relation "org_members" does not exist'),
+ ),
+ ).toBe(true);
+ });
});
diff --git a/packages/core/src/org/membership.ts b/packages/core/src/org/membership.ts
index d31b605199a..ca28101b395 100644
--- a/packages/core/src/org/membership.ts
+++ b/packages/core/src/org/membership.ts
@@ -18,7 +18,7 @@ export function isMissingOrganizationTableError(error: unknown): boolean {
};
const message = String(candidate.message ?? "");
if (
- /no such table:\s*["'`]?organizations["'`]?|relation\s+["'`]?organizations["'`]?\s+does not exist/i.test(
+ /no such table:\s*["'`]?(?:organizations|org_members)["'`]?|relation\s+["'`]?(?:organizations|org_members)["'`]?\s+does not exist/i.test(
message,
)
) {
diff --git a/packages/core/src/org/workspace-app-access.spec.ts b/packages/core/src/org/workspace-app-access.spec.ts
index acf915543ec..9eb16c1508c 100644
--- a/packages/core/src/org/workspace-app-access.spec.ts
+++ b/packages/core/src/org/workspace-app-access.spec.ts
@@ -1,8 +1,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
+import { resetAppConfigForTests } from "../app-config/index.js";
+
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
includeUser: vi.fn(),
+ validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(),
}));
vi.mock("../db/client.js", () => ({
@@ -14,14 +17,21 @@ vi.mock("../workspace-connections/groups.js", () => ({
mocks.includeUser(...args),
}));
+vi.mock("./federation.js", () => ({
+ validateFederatedOrganizationMembershipForCurrentRequest:
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+}));
+
import { isWorkspaceAppAccessAllowed } from "./workspace-app-access.js";
describe("isWorkspaceAppAccessAllowed", () => {
afterEach(() => {
vi.unstubAllEnvs();
+ resetAppConfigForTests();
vi.unstubAllGlobals();
mocks.execute.mockReset();
mocks.includeUser.mockReset();
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockReset();
});
it("allows the recorded owner in the app organization", async () => {
@@ -43,22 +53,69 @@ describe("isWorkspaceAppAccessAllowed", () => {
).resolves.toBe(true);
});
- it.each(["owner", "admin"] as const)(
- "allows organization %s members to access Dispatch",
- async (role) => {
- mocks.execute.mockResolvedValueOnce({ rows: [{ role }] });
+ it("allows active organization members to access Dispatch", async () => {
+ mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] });
- await expect(
- isWorkspaceAppAccessAllowed("dispatch", {
- email: `${role}@example.com`,
- orgId: "org-1",
- }),
- ).resolves.toBe(true);
- },
- );
+ await expect(
+ isWorkspaceAppAccessAllowed("dispatch", {
+ email: "member@example.com",
+ orgId: "org-1",
+ }),
+ ).resolves.toBe(true);
+ });
- it("denies organization members access to Dispatch", async () => {
- mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] });
+ it("denies Dispatch access when a linked member was removed upstream", async () => {
+ mocks.execute.mockResolvedValueOnce({
+ rows: [
+ {
+ role: "admin",
+ identityAuthority: "https://identity.example.test",
+ identityId: "org-1",
+ },
+ ],
+ });
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
+ { active: false, role: null },
+ );
+
+ await expect(
+ isWorkspaceAppAccessAllowed("dispatch", {
+ email: "admin@example.com",
+ orgId: "org-1",
+ }),
+ ).resolves.toBe(false);
+ expect(
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+ ).toHaveBeenCalledWith({
+ orgId: "org-1",
+ email: "admin@example.com",
+ });
+ });
+
+ it("keeps standalone Dispatch available when its org schema is absent", async () => {
+ vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch");
+ resetAppConfigForTests();
+ mocks.execute
+ .mockRejectedValueOnce(new Error('relation "org_members" does not exist'))
+ .mockRejectedValueOnce(
+ new Error('relation "org_members" does not exist'),
+ );
+
+ await expect(
+ isWorkspaceAppAccessAllowed("dispatch", {
+ email: "member@example.com",
+ orgId: "org-1",
+ }),
+ ).resolves.toBe(true);
+ });
+
+ it("fails closed for hosted Dispatch when its org schema is absent", async () => {
+ vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch");
+ vi.stubEnv("AGENT_NATIVE_WORKSPACE", "1");
+ resetAppConfigForTests();
+ mocks.execute.mockRejectedValueOnce(
+ new Error('relation "org_members" does not exist'),
+ );
await expect(
isWorkspaceAppAccessAllowed("dispatch", {
diff --git a/packages/core/src/org/workspace-app-access.ts b/packages/core/src/org/workspace-app-access.ts
index 18351d2663d..1d3a9587690 100644
--- a/packages/core/src/org/workspace-app-access.ts
+++ b/packages/core/src/org/workspace-app-access.ts
@@ -1,7 +1,10 @@
import { signA2AToken } from "../a2a/client.js";
import { getAppConfig } from "../app-config/index.js";
import { getDbExec, type DbExec } from "../db/client.js";
-import { resolveVercelDeploymentProtectionHeaders } from "../server/credential-provider.js";
+import {
+ isHostedWorkspaceRuntime,
+ resolveVercelDeploymentProtectionHeaders,
+} from "../server/credential-provider.js";
import { workspaceUserGroupsIncludeUser } from "../workspace-connections/groups.js";
import { getOrgA2ASecret, getOrgDomain } from "./context.js";
import { isMissingOrganizationTableError } from "./membership.js";
@@ -24,6 +27,18 @@ function normalizedEmail(email: string): string {
return email.trim().toLowerCase();
}
+export function isStandaloneDispatchRuntime(): boolean {
+ const app = getAppConfig().app;
+ const isDispatch = [
+ app.id,
+ app.legacyId,
+ app.template,
+ app.slug,
+ app.packageName,
+ ].some((value) => value?.trim().toLowerCase() === "dispatch");
+ return isDispatch && !isHostedWorkspaceRuntime();
+}
+
function configuredWorkspaceDirectory(): string | null {
const workspace = getAppConfig().workspace;
return (
@@ -155,6 +170,7 @@ async function loadWorkspaceOrgMember(
});
} catch (error) {
if (!isMissingOrganizationTableError(error)) throw error;
+ if (!isStandaloneDispatchRuntime()) throw error;
memberResult = await db.execute({
sql: `SELECT role FROM org_members
WHERE org_id = ? AND LOWER(email) = ?
@@ -196,17 +212,22 @@ async function isDispatchWorkspaceAppAccessAllowed(
email: string,
): Promise {
const orgId = context.orgId?.trim() || null;
- // Dispatch remains available in personal/no-org mode. Organization-scoped
- // Dispatch is a private control plane for owners and admins.
if (!orgId) return true;
try {
const member = await loadWorkspaceOrgMember(getDbExec(), orgId, email);
- if (!member || !(await isActiveWorkspaceOrgMember(member, orgId, email))) {
- return false;
- }
- return member.role === "owner" || member.role === "admin";
+ // Standalone Dispatch hosts can carry an org id before enabling the org
+ // schema. Preserve their authenticated-only access until that schema exists.
+ return Boolean(
+ member && (await isActiveWorkspaceOrgMember(member, orgId, email)),
+ );
} catch (error) {
+ if (
+ isMissingOrganizationTableError(error) &&
+ isStandaloneDispatchRuntime()
+ ) {
+ return true;
+ }
console.error("[workspace-app-access] Dispatch access check failed", error);
return false;
}
diff --git a/packages/core/src/server/auth.spec.ts b/packages/core/src/server/auth.spec.ts
index d17eef21a62..f01b4a71b68 100644
--- a/packages/core/src/server/auth.spec.ts
+++ b/packages/core/src/server/auth.spec.ts
@@ -2293,19 +2293,9 @@ describe("server/auth", () => {
expect(actionResult).toEqual({ error: "Unauthorized" });
});
- it("protects standalone Dispatch APIs for organization members", async () => {
+ it("allows standalone Dispatch APIs for organization members", async () => {
vi.stubEnv("NODE_ENV", "production");
vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch");
- vi.doMock("../db/client.js", () => ({
- getDbExec: () => ({
- execute: vi.fn(async (statement: unknown) => {
- const sql = String((statement as { sql?: unknown })?.sql ?? "");
- return sql.includes("FROM org_members")
- ? { rows: [{ role: "member" }] }
- : { rows: [] };
- }),
- }),
- }));
const { autoMountAuth } = await import("./auth.js");
const app = createMockApp();
@@ -2323,10 +2313,8 @@ describe("server/auth", () => {
path: "/_agent-native/actions/list",
});
- await expect(guard(event)).resolves.toEqual({
- error: "You do not have access to this workspace app.",
- });
- expect(event.res.status).toBe(403);
+ await expect(guard(event)).resolves.toBeUndefined();
+ expect(event.res.status).not.toBe(403);
});
it("does not apply Dispatch access to a renamed Dispatch scaffold", async () => {
diff --git a/packages/dispatch/src/actions/apply-dream-proposal.ts b/packages/dispatch/src/actions/apply-dream-proposal.ts
index 3ab1d26be2b..9ac2dbf7fe0 100644
--- a/packages/dispatch/src/actions/apply-dream-proposal.ts
+++ b/packages/dispatch/src/actions/apply-dream-proposal.ts
@@ -1,11 +1,15 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
-import { applyDreamProposal } from "../server/lib/dreams-store.js";
+import {
+ applyDreamProposal,
+ authorizeDreamProposalMutation,
+} from "../server/lib/dreams-store.js";
export default defineAction({
description:
"Apply one pending Dispatch dream proposal. Personal memory applies directly; shared LEARNINGS.md and workspace resource proposals queue an approval request when approval policy is enabled.",
+ authorize: authorizeDreamProposalMutation,
schema: z.object({
id: z.string().min(1).describe("Dream proposal id."),
}),
diff --git a/packages/dispatch/src/actions/approve-dispatch-change.ts b/packages/dispatch/src/actions/approve-dispatch-change.ts
index 7876da68321..99622cd5c69 100644
--- a/packages/dispatch/src/actions/approve-dispatch-change.ts
+++ b/packages/dispatch/src/actions/approve-dispatch-change.ts
@@ -1,10 +1,12 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { approveRequest } from "../server/lib/dispatch-store.js";
export default defineAction({
description: "Approve a pending dispatch change request and apply it.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().describe("Approval request id"),
}),
diff --git a/packages/dispatch/src/actions/create-pylon-ticket.ts b/packages/dispatch/src/actions/create-pylon-ticket.ts
index 2720d82dedf..134bcbf6adf 100644
--- a/packages/dispatch/src/actions/create-pylon-ticket.ts
+++ b/packages/dispatch/src/actions/create-pylon-ticket.ts
@@ -2,6 +2,7 @@ import { defineAction } from "@agent-native/core/action";
import { resolveSecret } from "@agent-native/core/server";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { recordAudit } from "../server/lib/dispatch-store.js";
const PYLON_API_BASE =
@@ -10,6 +11,7 @@ const PYLON_API_BASE =
export default defineAction({
description:
"Create a Pylon ticket. Use to escalate blockers from client meetings, route unmatched #customer-* posts that have no Slack channel, or open a follow-up that needs tracking. Requires PYLON_API_KEY in the Vault.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
title: z.string().min(1).describe("Short ticket title"),
bodyHtml: z
diff --git a/packages/dispatch/src/actions/create-workspace-resource-grant.ts b/packages/dispatch/src/actions/create-workspace-resource-grant.ts
index c4680dd3b13..2443f826798 100644
--- a/packages/dispatch/src/actions/create-workspace-resource-grant.ts
+++ b/packages/dispatch/src/actions/create-workspace-resource-grant.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { createResourceGrant } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
"Grant an app access to a workspace resource (skill, instruction, agent, knowledge pack, or MCP server). Admin only.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
resourceId: z.string().describe("Workspace resource ID"),
appId: z
diff --git a/packages/dispatch/src/actions/create-workspace-resource.ts b/packages/dispatch/src/actions/create-workspace-resource.ts
index 09ab117ac9d..cfe0571884b 100644
--- a/packages/dispatch/src/actions/create-workspace-resource.ts
+++ b/packages/dispatch/src/actions/create-workspace-resource.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { createWorkspaceResource } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
'Create a workspace-wide skill, instruction, agent profile, reference resource, or MCP server. Set scope to "all" for runtime inheritance by every app, or "selected" to grant per-app. When Dispatch approval policy is enabled, All-app creates queue an approval request before taking effect.',
+ authorize: authorizeDispatchAdmin,
schema: z.object({
kind: z
.enum([
diff --git a/packages/dispatch/src/actions/delete-destination.ts b/packages/dispatch/src/actions/delete-destination.ts
index 1806c943878..26a40f56796 100644
--- a/packages/dispatch/src/actions/delete-destination.ts
+++ b/packages/dispatch/src/actions/delete-destination.ts
@@ -1,10 +1,12 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { deleteDestination } from "../server/lib/dispatch-store.js";
export default defineAction({
description: "Delete a saved dispatch destination.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().describe("Destination id"),
}),
diff --git a/packages/dispatch/src/actions/delete-workspace-resource.ts b/packages/dispatch/src/actions/delete-workspace-resource.ts
index 753ed232cde..ecb1fc3f0b0 100644
--- a/packages/dispatch/src/actions/delete-workspace-resource.ts
+++ b/packages/dispatch/src/actions/delete-workspace-resource.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { deleteWorkspaceResource } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
"Delete a workspace resource and revoke all its grants. When Dispatch approval policy is enabled, deleting an All-app resource queues an approval request before taking effect.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().describe("Resource ID to delete"),
}),
diff --git a/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts
new file mode 100644
index 00000000000..aee1db3afdf
--- /dev/null
+++ b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts
@@ -0,0 +1,114 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ assertAny: vi.fn(),
+ deleteDestination: vi.fn(),
+ getDestinationById: vi.fn(),
+ resolveSecret: vi.fn(),
+ upsertDestination: vi.fn(),
+ validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(),
+}));
+
+vi.mock("@agent-native/core/org", () => ({
+ defineAppRoles: () => ({ assertAny: mocks.assertAny }),
+ validateFederatedOrganizationMembershipForCurrentRequest:
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+}));
+
+vi.mock("@agent-native/core/server", () => ({
+ getRequestOrgId: () => "org-a",
+ getRequestUserEmail: () => "member@example.com",
+ resolveSecret: mocks.resolveSecret,
+}));
+
+vi.mock("@agent-native/core/integrations", () => ({
+ listIntegrationInstallations: vi.fn(),
+ resolveIntegrationTokenBundle: vi.fn(),
+}));
+
+vi.mock("../server/lib/dispatch-store.js", () => ({
+ deleteDestination: mocks.deleteDestination,
+ getDestinationById: mocks.getDestinationById,
+ recordAudit: vi.fn(),
+ upsertDestination: mocks.upsertDestination,
+}));
+
+import { ForbiddenError } from "@agent-native/core/sharing";
+
+const sendPlatformMessage = (await import("./send-platform-message.js"))
+ .default;
+const createPylonTicket = (await import("./create-pylon-ticket.js")).default;
+const deleteDestination = (await import("./delete-destination.js")).default;
+const upsertDestination = (await import("./upsert-destination.js")).default;
+
+const memberContext = {
+ caller: "http" as const,
+ orgId: "org-a",
+ userEmail: "member@example.com",
+};
+
+describe("Dispatch admin authorization", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
+ { active: true, role: "member" },
+ );
+ mocks.assertAny.mockRejectedValue(
+ new ForbiddenError("Requires dispatch role admin"),
+ );
+ });
+
+ it("denies organization members before sending a platform message", async () => {
+ await expect(
+ sendPlatformMessage.run(
+ {
+ platform: "slack",
+ destination: "C1",
+ text: "hello",
+ },
+ memberContext,
+ ),
+ ).rejects.toThrow("Requires dispatch role admin");
+
+ expect(mocks.getDestinationById).not.toHaveBeenCalled();
+ expect(mocks.resolveSecret).not.toHaveBeenCalled();
+ });
+
+ it("denies organization members before creating a Pylon ticket", async () => {
+ await expect(
+ createPylonTicket.run(
+ {
+ title: "Follow-up",
+ bodyHtml: "hello
",
+ requesterEmail: "requester@example.com",
+ },
+ memberContext,
+ ),
+ ).rejects.toThrow("Requires dispatch role admin");
+
+ expect(mocks.resolveSecret).not.toHaveBeenCalled();
+ });
+
+ it("denies organization members before changing a destination", async () => {
+ await expect(
+ upsertDestination.run(
+ {
+ name: "Support",
+ platform: "slack",
+ destination: "C1",
+ },
+ memberContext,
+ ),
+ ).rejects.toThrow("Requires dispatch role admin");
+
+ expect(mocks.upsertDestination).not.toHaveBeenCalled();
+ });
+
+ it("denies organization members before deleting a destination", async () => {
+ await expect(
+ deleteDestination.run({ id: "destination-1" }, memberContext),
+ ).rejects.toThrow("Requires dispatch role admin");
+
+ expect(mocks.deleteDestination).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/dispatch/src/actions/ensure-dream-job.ts b/packages/dispatch/src/actions/ensure-dream-job.ts
index e66eb15fb94..bc63ff0c05e 100644
--- a/packages/dispatch/src/actions/ensure-dream-job.ts
+++ b/packages/dispatch/src/actions/ensure-dream-job.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { ensureDreamJob } from "../server/lib/dreams-store.js";
export default defineAction({
description:
"Create or update the personal recurring Dispatch dream job resource at jobs/dispatch-dream.md.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
schedule: z
.string()
diff --git a/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts b/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts
index 2d6979bc384..7629d6aad70 100644
--- a/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts
+++ b/packages/dispatch/src/actions/grant-workspace-resources-to-app.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { grantWorkspaceResourcesToApp } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
"Grant several selected workspace resources, knowledge packs, or MCP servers to an app, skipping existing active grants.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
appId: z.string().describe("App ID receiving the resources"),
resourceIds: z
diff --git a/packages/dispatch/src/actions/import-agent-pack.ts b/packages/dispatch/src/actions/import-agent-pack.ts
index ee47d258716..58a7b5e575f 100644
--- a/packages/dispatch/src/actions/import-agent-pack.ts
+++ b/packages/dispatch/src/actions/import-agent-pack.ts
@@ -13,6 +13,7 @@ import {
} from "../lib/agent-pack.js";
import { validateImportedAgentTools } from "../lib/simple-agent-profile.js";
import { applyAgentPackCreate } from "../server/lib/agent-pack-store.js";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import {
createApprovalRequest,
getApprovalPolicy,
@@ -60,6 +61,7 @@ function availableToolNames(dispatchToolNames: string[]): Set {
export default defineAction({
description:
"Import a folder-backed agent pack from Claude, Cowork, or another agent tool. The pack can include a Markdown/JSON profile, context, references, and skills. Credentials, hooks, shell commands, and local environment settings are never imported.",
+ authorize: authorizeDispatchAdmin,
schema,
run: async ({ files, scope }) => {
const normalized = normalizeAgentPack(files as AgentPackFileInput[]);
diff --git a/packages/dispatch/src/actions/import-agent.ts b/packages/dispatch/src/actions/import-agent.ts
index 9c16110939f..d4a46d79dcf 100644
--- a/packages/dispatch/src/actions/import-agent.ts
+++ b/packages/dispatch/src/actions/import-agent.ts
@@ -8,6 +8,7 @@ import {
normalizeImportedAgent,
validateImportedAgentTools,
} from "../lib/simple-agent-profile.js";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import {
createWorkspaceResource,
listWorkspaceResources,
@@ -16,6 +17,7 @@ import {
export default defineAction({
description:
"Import a Claude-style Markdown agent or a generic JSON agent definition into a reusable Dispatch agent profile. Credentials, shell commands, hooks, and local environment settings are never imported. Use connect-external-agent for an HTTP/A2A endpoint.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
source: z
.string()
diff --git a/packages/dispatch/src/actions/reject-dispatch-change.ts b/packages/dispatch/src/actions/reject-dispatch-change.ts
index 4a78f0a57a5..a32be038016 100644
--- a/packages/dispatch/src/actions/reject-dispatch-change.ts
+++ b/packages/dispatch/src/actions/reject-dispatch-change.ts
@@ -1,10 +1,12 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { rejectRequest } from "../server/lib/dispatch-store.js";
export default defineAction({
description: "Reject a pending dispatch change request.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().describe("Approval request id"),
reason: z.string().optional().describe("Optional rejection reason"),
diff --git a/packages/dispatch/src/actions/reject-dream-proposal.ts b/packages/dispatch/src/actions/reject-dream-proposal.ts
index 736f011e9e9..d98530e2f8e 100644
--- a/packages/dispatch/src/actions/reject-dream-proposal.ts
+++ b/packages/dispatch/src/actions/reject-dream-proposal.ts
@@ -1,10 +1,14 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
-import { rejectDreamProposal } from "../server/lib/dreams-store.js";
+import {
+ authorizeDreamProposalMutation,
+ rejectDreamProposal,
+} from "../server/lib/dreams-store.js";
export default defineAction({
description: "Reject one pending Dispatch dream proposal.",
+ authorize: authorizeDreamProposalMutation,
schema: z.object({
id: z.string().min(1).describe("Dream proposal id."),
reason: z.string().optional().describe("Optional rejection reason."),
diff --git a/packages/dispatch/src/actions/restore-starter-workspace-resources.ts b/packages/dispatch/src/actions/restore-starter-workspace-resources.ts
index de4dbe6e60b..e080be54793 100644
--- a/packages/dispatch/src/actions/restore-starter-workspace-resources.ts
+++ b/packages/dispatch/src/actions/restore-starter-workspace-resources.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { restoreStarterWorkspaceResources } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
"Restore missing starter global workspace resources such as company, brand, messaging, guardrails, and company voice. Existing resources are left unchanged.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
paths: z
.array(z.string())
diff --git a/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts b/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts
index 58c688b941a..fd3ac629000 100644
--- a/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts
+++ b/packages/dispatch/src/actions/revoke-workspace-resource-grant.ts
@@ -1,6 +1,7 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import {
requireWorkspaceResourceCtx,
revokeResourceGrant,
@@ -8,6 +9,7 @@ import {
export default defineAction({
description: "Revoke an app's access to a workspace resource. Admin only.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
grantId: z.string().describe("Grant ID to revoke"),
}),
diff --git a/packages/dispatch/src/actions/send-platform-message.spec.ts b/packages/dispatch/src/actions/send-platform-message.spec.ts
index c10bc025ad1..096f240cc6e 100644
--- a/packages/dispatch/src/actions/send-platform-message.spec.ts
+++ b/packages/dispatch/src/actions/send-platform-message.spec.ts
@@ -28,6 +28,10 @@ vi.mock("../server/lib/dispatch-store.js", () => ({
getDestinationById: mocks.getDestinationById,
}));
+vi.mock("../server/lib/app-roles.js", () => ({
+ authorizeDispatchAdmin: vi.fn(),
+}));
+
const action = (await import("./send-platform-message.js")).default;
describe("send-platform-message tenant scoping", () => {
diff --git a/packages/dispatch/src/actions/send-platform-message.ts b/packages/dispatch/src/actions/send-platform-message.ts
index 1412bab100f..18959f6bc3a 100644
--- a/packages/dispatch/src/actions/send-platform-message.ts
+++ b/packages/dispatch/src/actions/send-platform-message.ts
@@ -14,6 +14,7 @@ import {
} from "@agent-native/core/server";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { getDestinationById } from "../server/lib/dispatch-store.js";
function getAdapter(
@@ -78,6 +79,7 @@ async function assertOutboundConfigured(
export default defineAction({
description:
"Send a proactive message to a saved Slack, Telegram, or email destination.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
platform: z.enum(["slack", "telegram", "email"]).optional(),
destinationId: z.string().optional().describe("Saved destination id"),
diff --git a/packages/dispatch/src/actions/set-dispatch-approval-policy.ts b/packages/dispatch/src/actions/set-dispatch-approval-policy.ts
index fca43941411..6729dbf6f95 100644
--- a/packages/dispatch/src/actions/set-dispatch-approval-policy.ts
+++ b/packages/dispatch/src/actions/set-dispatch-approval-policy.ts
@@ -1,6 +1,7 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import {
getApprovalPolicy,
setApprovalPolicy,
@@ -8,6 +9,7 @@ import {
export default defineAction({
description: "Enable or disable dispatch approval flow for durable changes.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
enabled: z.boolean(),
approverEmails: z.array(z.string().email()).default([]),
diff --git a/packages/dispatch/src/actions/set-dream-settings.spec.ts b/packages/dispatch/src/actions/set-dream-settings.spec.ts
index 9c9c67082ef..a5bb61304e8 100644
--- a/packages/dispatch/src/actions/set-dream-settings.spec.ts
+++ b/packages/dispatch/src/actions/set-dream-settings.spec.ts
@@ -1,9 +1,14 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
+ authorizeDispatchAdmin: vi.fn(),
setDreamSettings: vi.fn(),
}));
+vi.mock("../server/lib/app-roles.js", () => ({
+ authorizeDispatchAdmin: mocks.authorizeDispatchAdmin,
+}));
+
vi.mock("../server/lib/dreams-store.js", () => ({
setDreamSettings: mocks.setDreamSettings,
}));
diff --git a/packages/dispatch/src/actions/set-dream-settings.ts b/packages/dispatch/src/actions/set-dream-settings.ts
index c0f63f1350d..0cab49825cd 100644
--- a/packages/dispatch/src/actions/set-dream-settings.ts
+++ b/packages/dispatch/src/actions/set-dream-settings.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { setDreamSettings } from "../server/lib/dreams-store.js";
export default defineAction({
description:
"Update recurring Dispatch dream settings without immediately running or applying a dream pass.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
enabled: z
.boolean()
diff --git a/packages/dispatch/src/actions/update-workspace-resource.ts b/packages/dispatch/src/actions/update-workspace-resource.ts
index 1d3287efc50..e48846dc18b 100644
--- a/packages/dispatch/src/actions/update-workspace-resource.ts
+++ b/packages/dispatch/src/actions/update-workspace-resource.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { updateWorkspaceResource } from "../server/lib/workspace-resources-store.js";
export default defineAction({
description:
"Update a workspace resource's name, description, content, or scope. When Dispatch approval policy is enabled, changes that affect All-app resources queue an approval request before taking effect.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().describe("Resource ID"),
name: z.string().optional().describe("New name"),
diff --git a/packages/dispatch/src/actions/upsert-destination.ts b/packages/dispatch/src/actions/upsert-destination.ts
index 01f24771e1a..7c9afb0af00 100644
--- a/packages/dispatch/src/actions/upsert-destination.ts
+++ b/packages/dispatch/src/actions/upsert-destination.ts
@@ -1,11 +1,13 @@
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
+import { authorizeDispatchAdmin } from "../server/lib/app-roles.js";
import { upsertDestination } from "../server/lib/dispatch-store.js";
export default defineAction({
description:
"Create or update a saved messaging destination for proactive Slack, Telegram, or email sends.",
+ authorize: authorizeDispatchAdmin,
schema: z.object({
id: z.string().optional(),
name: z.string().describe("Friendly destination name"),
diff --git a/packages/dispatch/src/components/dispatch-access.spec.tsx b/packages/dispatch/src/components/dispatch-access.spec.tsx
index 3d12b16e322..bd3381cc5db 100644
--- a/packages/dispatch/src/components/dispatch-access.spec.tsx
+++ b/packages/dispatch/src/components/dispatch-access.spec.tsx
@@ -4,19 +4,6 @@ import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-const mocks = vi.hoisted(() => ({
- useOrgRole: vi.fn(),
-}));
-
-vi.mock("@agent-native/core/client/org", async (importOriginal) => {
- const actual =
- await importOriginal();
- return {
- ...actual,
- useOrgRole: mocks.useOrgRole,
- };
-});
-
import { RequireDispatchAccess } from "./dispatch-access.js";
describe("RequireDispatchAccess", () => {
@@ -33,40 +20,10 @@ describe("RequireDispatchAccess", () => {
afterEach(() => {
act(() => root.unmount());
container.remove();
- mocks.useOrgRole.mockReset();
vi.unstubAllGlobals();
});
- it.each(["owner", "admin"] as const)(
- "renders the Dispatch shell for an organization %s",
- (role) => {
- mocks.useOrgRole.mockReturnValue({
- org: { orgId: "org-1" },
- role,
- isLoading: false,
- error: null,
- });
-
- act(() => {
- root.render(
-
- Dispatch shell
- ,
- );
- });
-
- expect(container.querySelector("[data-dispatch-shell]")).not.toBeNull();
- },
- );
-
- it("renders the Dispatch shell without an active organization", () => {
- mocks.useOrgRole.mockReturnValue({
- org: undefined,
- role: null,
- isLoading: false,
- error: null,
- });
-
+ it("renders the Dispatch shell for every authenticated user", () => {
act(() => {
root.render(
@@ -77,23 +34,4 @@ describe("RequireDispatchAccess", () => {
expect(container.querySelector("[data-dispatch-shell]")).not.toBeNull();
});
-
- it("does not render the Dispatch shell for an organization member", () => {
- mocks.useOrgRole.mockReturnValue({
- org: { orgId: "org-1" },
- role: "member",
- isLoading: false,
- error: null,
- });
-
- act(() => {
- root.render(
-
- Dispatch shell
- ,
- );
- });
-
- expect(container.querySelector("[data-dispatch-shell]")).toBeNull();
- });
});
diff --git a/packages/dispatch/src/components/dispatch-access.tsx b/packages/dispatch/src/components/dispatch-access.tsx
index 512f3bae8aa..31ab99ba762 100644
--- a/packages/dispatch/src/components/dispatch-access.tsx
+++ b/packages/dispatch/src/components/dispatch-access.tsx
@@ -1,13 +1,5 @@
-import { canManageOrg, useOrgRole } from "@agent-native/core/client/org";
-import { DefaultSpinner } from "@agent-native/core/client/ui";
import { type ReactNode } from "react";
export function RequireDispatchAccess({ children }: { children: ReactNode }) {
- const { org, role, isLoading, error } = useOrgRole();
-
- if (isLoading) return ;
- if (error) return null;
- if (org?.orgId && !canManageOrg(role)) return null;
-
return <>{children}>;
}
diff --git a/packages/dispatch/src/server/lib/app-creation-store.spec.ts b/packages/dispatch/src/server/lib/app-creation-store.spec.ts
index 75c4c4016e5..8c80e5c329c 100644
--- a/packages/dispatch/src/server/lib/app-creation-store.spec.ts
+++ b/packages/dispatch/src/server/lib/app-creation-store.spec.ts
@@ -35,6 +35,7 @@ const mocks = vi.hoisted(() => {
settings.set(key, value);
}),
getOrgSetting: vi.fn(async () => null),
+ isWorkspaceAppAccessAllowed: vi.fn(async () => true),
resolveAccess: vi.fn(async () => ({
role: "viewer",
resource: {},
@@ -106,12 +107,8 @@ vi.mock("@agent-native/core/org", async (importOriginal) => {
await importOriginal();
return {
...actual,
- isWorkspaceAppAccessAllowed: vi.fn(
- async (_appId: string, context: { orgId?: string | null } = {}) =>
- !context.orgId ||
- mocks.state.orgRole === "owner" ||
- mocks.state.orgRole === "admin",
- ),
+ isWorkspaceAppAccessAllowed: (...args: any[]) =>
+ mocks.isWorkspaceAppAccessAllowed(...args),
};
});
@@ -158,6 +155,8 @@ afterEach(() => {
mocks.settings.clear();
mocks.getOrgSetting.mockReset();
mocks.getOrgSetting.mockResolvedValue(null);
+ mocks.isWorkspaceAppAccessAllowed.mockReset();
+ mocks.isWorkspaceAppAccessAllowed.mockResolvedValue(true);
mocks.mutateSetting.mockReset();
mocks.mutateSetting.mockImplementation(
async (key: string, updater: (current: any) => any) => {
@@ -296,22 +295,6 @@ describe("listWorkspaceApps", () => {
};
}
- it.each(["owner", "admin", "member"] as const)(
- "applies the organization role gate to Dispatch registry links for %s",
- async (role) => {
- stubNoPendingContext();
- stubManifest();
- mocks.state.orgRole = role;
-
- const apps = await runWithRequestContext(
- { userEmail: `${role}@example.test`, orgId: "org-123" },
- () => listWorkspaceApps({ includeAgentCards: false }),
- );
-
- expect(apps.some((app) => app.id === "dispatch")).toBe(role !== "member");
- },
- );
-
it("prefers the live workspace gateway manifest when available", async () => {
const fetchMock = vi.fn(async () => {
return new Response(
@@ -598,6 +581,22 @@ describe("listWorkspaceApps", () => {
expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
});
+ it("does not expose Dispatch after federated membership is revoked", async () => {
+ stubManifest();
+ mocks.isWorkspaceAppAccessAllowed.mockResolvedValueOnce(false);
+
+ const apps = await runWithRequestContext(
+ { userEmail: "member@example.test", orgId: "org-123" },
+ () => listWorkspaceApps({ includeAgentCards: false }),
+ );
+
+ expect(apps).toEqual([]);
+ expect(mocks.isWorkspaceAppAccessAllowed).toHaveBeenCalledWith("dispatch", {
+ email: "member@example.test",
+ orgId: "org-123",
+ });
+ });
+
it("does not expose the workspace app registry without an authenticated user", async () => {
stubNoPendingContext();
stubManifest([
diff --git a/packages/dispatch/src/server/lib/app-creation-store.ts b/packages/dispatch/src/server/lib/app-creation-store.ts
index 006b5f2ff92..5649ecc6724 100644
--- a/packages/dispatch/src/server/lib/app-creation-store.ts
+++ b/packages/dispatch/src/server/lib/app-creation-store.ts
@@ -1450,7 +1450,7 @@ async function filterWorkspaceAppsByAccess(
}
if (app.isDispatch) {
if (
- await isWorkspaceAppAccessAllowed(app.id, {
+ await isWorkspaceAppAccessAllowed("dispatch", {
email: userEmail,
orgId,
})
diff --git a/packages/dispatch/src/server/lib/app-roles.spec.ts b/packages/dispatch/src/server/lib/app-roles.spec.ts
new file mode 100644
index 00000000000..f877d9a7a12
--- /dev/null
+++ b/packages/dispatch/src/server/lib/app-roles.spec.ts
@@ -0,0 +1,120 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const mocks = vi.hoisted(() => ({
+ assertAny: vi.fn(),
+ getRequestOrgId: vi.fn(),
+ getRequestUserEmail: vi.fn(),
+ isStandaloneDispatchRuntime: vi.fn(),
+ validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(),
+}));
+
+vi.mock("@agent-native/core/org", () => ({
+ defineAppRoles: () => ({ assertAny: mocks.assertAny }),
+ isMissingOrganizationTableError: (error: unknown) =>
+ /(?:organizations|org_members).*does not exist/i.test(String(error)),
+ isStandaloneDispatchRuntime: mocks.isStandaloneDispatchRuntime,
+ validateFederatedOrganizationMembershipForCurrentRequest:
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+}));
+
+vi.mock("@agent-native/core/server", () => ({
+ getRequestOrgId: mocks.getRequestOrgId,
+ getRequestUserEmail: mocks.getRequestUserEmail,
+}));
+
+import { ForbiddenError } from "@agent-native/core/sharing";
+
+import { authorizeDispatchAdmin } from "./app-roles.js";
+
+const context = {
+ caller: "http" as const,
+ orgId: "org-1",
+ userEmail: "member@example.test",
+};
+
+describe("authorizeDispatchAdmin", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
+ { active: true, role: "member" },
+ );
+ mocks.assertAny.mockResolvedValue("admin");
+ mocks.getRequestOrgId.mockReturnValue(undefined);
+ mocks.getRequestUserEmail.mockReturnValue(undefined);
+ mocks.isStandaloneDispatchRuntime.mockReturnValue(false);
+ });
+
+ it("denies an organization member without the Dispatch admin role", async () => {
+ mocks.assertAny.mockRejectedValue(
+ new ForbiddenError("Requires dispatch role admin"),
+ );
+
+ await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow(
+ "Requires dispatch role admin",
+ );
+ expect(
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+ ).toHaveBeenCalledWith({
+ orgId: "org-1",
+ email: "member@example.test",
+ });
+ expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], {
+ orgId: "org-1",
+ userEmail: "member@example.test",
+ });
+ });
+
+ it("allows an organization admin without an app-role assignment", async () => {
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
+ { active: true, role: "admin" },
+ );
+
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
+ expect(mocks.assertAny).not.toHaveBeenCalled();
+ });
+
+ it("denies a stale linked organization admin", async () => {
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue(
+ { active: false, role: null },
+ );
+
+ await expect(authorizeDispatchAdmin({}, context)).rejects.toThrow(
+ "active organization membership",
+ );
+ expect(mocks.assertAny).not.toHaveBeenCalled();
+ });
+
+ it("allows standalone administration when the org schema is absent", async () => {
+ mocks.isStandaloneDispatchRuntime.mockReturnValue(true);
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockRejectedValue(
+ new Error('relation "org_members" does not exist'),
+ );
+
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
+ expect(mocks.assertAny).not.toHaveBeenCalled();
+ });
+
+ it("allows a member with the Dispatch admin role", async () => {
+ await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined();
+ expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], {
+ orgId: "org-1",
+ userEmail: "member@example.test",
+ });
+ });
+
+ it("allows authenticated personal-mode administration", async () => {
+ await expect(
+ authorizeDispatchAdmin({}, { ...context, orgId: null }),
+ ).resolves.toBeUndefined();
+ expect(
+ mocks.validateFederatedOrganizationMembershipForCurrentRequest,
+ ).not.toHaveBeenCalled();
+ expect(mocks.assertAny).not.toHaveBeenCalled();
+ });
+
+ it("denies an unauthenticated caller", async () => {
+ await expect(
+ authorizeDispatchAdmin({}, { ...context, userEmail: undefined }),
+ ).rejects.toBeInstanceOf(ForbiddenError);
+ });
+});
diff --git a/packages/dispatch/src/server/lib/app-roles.ts b/packages/dispatch/src/server/lib/app-roles.ts
new file mode 100644
index 00000000000..5c4dbbcce35
--- /dev/null
+++ b/packages/dispatch/src/server/lib/app-roles.ts
@@ -0,0 +1,67 @@
+import type { ActionRunContext } from "@agent-native/core/action";
+import {
+ defineAppRoles,
+ isMissingOrganizationTableError,
+ isStandaloneDispatchRuntime,
+ type AppRoles,
+ validateFederatedOrganizationMembershipForCurrentRequest,
+} from "@agent-native/core/org";
+import {
+ getRequestOrgId,
+ getRequestUserEmail,
+} from "@agent-native/core/server";
+import { ForbiddenError } from "@agent-native/core/sharing";
+
+import { dispatchAccessDescriptor } from "../../shared/app-roles.js";
+
+let dispatchAccess: AppRoles<"admin"> | undefined;
+
+function getDispatchAccess(): AppRoles<"admin"> {
+ return (dispatchAccess ??= defineAppRoles(dispatchAccessDescriptor));
+}
+
+/**
+ * Keep the Dispatch shell open to every signed-in member while protecting
+ * workspace-wide administration operations for org or Dispatch admins.
+ */
+export async function authorizeDispatchAdmin(
+ _args: unknown,
+ ctx?: ActionRunContext,
+): Promise {
+ const email =
+ ctx?.userEmail !== undefined ? ctx.userEmail : getRequestUserEmail();
+ const orgId = ctx?.orgId !== undefined ? ctx.orgId : getRequestOrgId();
+ if (!email?.trim()) {
+ throw new ForbiddenError(
+ "Dispatch administration requires an authenticated user.",
+ );
+ }
+ if (!orgId?.trim()) return;
+ let membership;
+ try {
+ membership = await validateFederatedOrganizationMembershipForCurrentRequest(
+ {
+ orgId,
+ email,
+ },
+ );
+ } catch (error) {
+ if (
+ isMissingOrganizationTableError(error) &&
+ isStandaloneDispatchRuntime()
+ ) {
+ return;
+ }
+ throw error;
+ }
+ if (!membership.active) {
+ throw new ForbiddenError(
+ "Dispatch administration requires active organization membership.",
+ );
+ }
+ if (membership.role === "owner" || membership.role === "admin") return;
+ await getDispatchAccess().assertAny(["admin"], {
+ userEmail: email,
+ orgId,
+ });
+}
diff --git a/packages/dispatch/src/server/lib/approval-fencing.spec.ts b/packages/dispatch/src/server/lib/approval-fencing.spec.ts
index 7df238bdbac..a6d79ef18be 100644
--- a/packages/dispatch/src/server/lib/approval-fencing.spec.ts
+++ b/packages/dispatch/src/server/lib/approval-fencing.spec.ts
@@ -340,6 +340,45 @@ describe("dispatch approval request status fencing", () => {
});
});
+ it("does not let the same email approve a request from another organization", async () => {
+ const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
+ await Promise.all([
+ import("@agent-native/core/server"),
+ import("@agent-native/core/db"),
+ import("./dispatch-store.js"),
+ ]);
+ const exec = getDbExec();
+
+ const requestId = await runWithRequestContext(
+ { userEmail: ownerEmail, orgId },
+ async () => {
+ const created = await dispatchStore.createApprovalRequest({
+ changeType: "approval-policy.update",
+ targetType: "dispatch-settings",
+ targetId: "dispatch-approval-policy",
+ summary: "Keep this request in its organization",
+ payload: { enabled: true, approverEmails: [] },
+ });
+ return (created as any).id as string;
+ },
+ );
+
+ await runWithRequestContext(
+ { userEmail: ownerEmail, orgId: otherOrgId },
+ async () => {
+ await expect(dispatchStore.approveRequest(requestId)).rejects.toThrow(
+ "Approval request not found",
+ );
+ },
+ );
+
+ const rows = await exec.execute({
+ sql: "SELECT status FROM dispatch_approval_requests WHERE id = ?",
+ args: [requestId],
+ });
+ expect(rows.rows[0]).toMatchObject({ status: "pending" });
+ });
+
it("does not let a caller from a different tenant reject another tenant's request", async () => {
const [{ runWithRequestContext }, { getDbExec }, dispatchStore] =
await Promise.all([
diff --git a/packages/dispatch/src/server/lib/dispatch-store.ts b/packages/dispatch/src/server/lib/dispatch-store.ts
index 07ee2e6f16d..2857c9d7033 100644
--- a/packages/dispatch/src/server/lib/dispatch-store.ts
+++ b/packages/dispatch/src/server/lib/dispatch-store.ts
@@ -157,7 +157,10 @@ function ctxScope(
if (!ctx.orgId) {
return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId));
}
- return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId));
+ return or(
+ and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)),
+ eq(table.orgId, ctx.orgId),
+ );
}
function id() {
diff --git a/packages/dispatch/src/server/lib/dreams-store.spec.ts b/packages/dispatch/src/server/lib/dreams-store.spec.ts
index b0317c1e59b..20e88fd3187 100644
--- a/packages/dispatch/src/server/lib/dreams-store.spec.ts
+++ b/packages/dispatch/src/server/lib/dreams-store.spec.ts
@@ -4,6 +4,7 @@ const mocks = vi.hoisted(() => ({
getDb: vi.fn(),
currentOwnerEmail: vi.fn(() => "owner@example.test"),
currentOrgId: vi.fn(() => null),
+ authorizeDispatchAdmin: vi.fn(),
getApprovalPolicy: vi.fn(),
createApprovalRequest: vi.fn(),
recordAudit: vi.fn(),
@@ -42,6 +43,10 @@ vi.mock("./dispatch-store.js", () => ({
recordAudit: mocks.recordAudit,
}));
+vi.mock("./app-roles.js", () => ({
+ authorizeDispatchAdmin: mocks.authorizeDispatchAdmin,
+}));
+
vi.mock("./thread-debug-store.js", () => ({
searchAgentThreads: mocks.searchAgentThreads,
getAgentThreadDebug: mocks.getAgentThreadDebug,
@@ -72,6 +77,7 @@ import { schema } from "../../db/index.js";
import {
applyApprovedDreamProposal,
applyDreamProposal,
+ authorizeDreamProposalMutation,
buildProposalInputs,
ensureDreamJob,
getDreamSettings,
@@ -301,6 +307,78 @@ afterEach(() => {
vi.clearAllMocks();
});
+describe("authorizeDreamProposalMutation", () => {
+ it("allows an owner to change a personal-memory proposal", async () => {
+ mocks.getDb.mockReturnValue(createDbMock(pendingProposal()));
+
+ await expect(
+ authorizeDreamProposalMutation(
+ { id: "proposal-1" },
+ {
+ caller: "http",
+ orgId: null,
+ userEmail: "owner@example.test",
+ },
+ ),
+ ).resolves.toBeUndefined();
+ expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled();
+ });
+
+ it("requires Dispatch admin authorization for shared proposals", async () => {
+ mocks.getDb.mockReturnValue(
+ createDbMock(
+ pendingProposal({
+ orgId: "org-1",
+ targetType: "shared-learnings",
+ targetPath: "LEARNINGS.md",
+ }),
+ ),
+ );
+ mocks.authorizeDispatchAdmin.mockResolvedValue(undefined);
+
+ await expect(
+ authorizeDreamProposalMutation(
+ { id: "proposal-1" },
+ {
+ caller: "http",
+ orgId: "org-1",
+ userEmail: "member@example.test",
+ },
+ ),
+ ).resolves.toBeUndefined();
+ expect(mocks.authorizeDispatchAdmin).toHaveBeenCalledWith(
+ { id: "proposal-1" },
+ expect.objectContaining({
+ orgId: "org-1",
+ userEmail: "member@example.test",
+ }),
+ );
+ });
+
+ it("does not let an organization member change another user's personal proposal", async () => {
+ mocks.getDb.mockReturnValue(
+ createDbMock(
+ pendingProposal({
+ ownerEmail: "other@example.test",
+ orgId: "org-1",
+ }),
+ ),
+ );
+
+ await expect(
+ authorizeDreamProposalMutation(
+ { id: "proposal-1" },
+ {
+ caller: "http",
+ orgId: "org-1",
+ userEmail: "member@example.test",
+ },
+ ),
+ ).rejects.toThrow("can only be changed by their owner");
+ expect(mocks.authorizeDispatchAdmin).not.toHaveBeenCalled();
+ });
+});
+
describe("listDreamCandidates", () => {
it("scores grounded thread signals and keeps per-thread debug errors isolated", async () => {
mocks.searchAgentThreads.mockResolvedValue({
diff --git a/packages/dispatch/src/server/lib/dreams-store.ts b/packages/dispatch/src/server/lib/dreams-store.ts
index 27d86ed795f..30b3dde897f 100644
--- a/packages/dispatch/src/server/lib/dreams-store.ts
+++ b/packages/dispatch/src/server/lib/dreams-store.ts
@@ -1,5 +1,6 @@
import crypto from "node:crypto";
+import type { ActionRunContext } from "@agent-native/core/action";
import {
and,
desc,
@@ -20,8 +21,10 @@ import {
putOrgSetting,
putUserSetting,
} from "@agent-native/core/settings";
+import { ForbiddenError } from "@agent-native/core/sharing";
import { getDb, schema } from "../../db/index.js";
+import { authorizeDispatchAdmin } from "./app-roles.js";
import {
createApprovalRequest,
currentOrgId,
@@ -444,7 +447,10 @@ function scopeFor(
if (!ctx.orgId) {
return and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId));
}
- return or(eq(table.ownerEmail, ctx.ownerEmail), eq(table.orgId, ctx.orgId));
+ return or(
+ and(eq(table.ownerEmail, ctx.ownerEmail), isNull(table.orgId)),
+ eq(table.orgId, ctx.orgId),
+ );
}
function safeJson(value: unknown): string {
@@ -2394,6 +2400,36 @@ async function getProposalRow(
return row ?? null;
}
+export async function authorizeDreamProposalMutation(
+ args: { id: string },
+ ctx?: ActionRunContext,
+): Promise {
+ const ownerEmail =
+ ctx?.userEmail !== undefined ? ctx.userEmail : currentOwnerEmail();
+ const orgId = ctx?.orgId !== undefined ? ctx.orgId : currentOrgId();
+ const proposal = await getProposalRow(args.id, {
+ ownerEmail,
+ orgId: orgId?.trim() || null,
+ });
+ if (!proposal) {
+ throw new ForbiddenError("Dream proposal not found");
+ }
+
+ if (proposal.targetType === "personal-memory") {
+ if (
+ proposal.ownerEmail.trim().toLowerCase() !==
+ ownerEmail.trim().toLowerCase()
+ ) {
+ throw new ForbiddenError(
+ "Personal memory proposals can only be changed by their owner",
+ );
+ }
+ return;
+ }
+
+ await authorizeDispatchAdmin(args, ctx);
+}
+
export async function createDreamReport(input: {
sourceId?: string;
sourceIds?: string[];