From 39e0dcab78c57b298dd91aa8df16d225a355c72f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 08:11:24 -0700 Subject: [PATCH 1/8] chore: publish branch work in .changeset/restrict-dispatch-org-access.md, packages/core, packages/dispatch (10 files) --- .changeset/restrict-dispatch-org-access.md | 2 +- .../core/src/client/org/OrgSwitcher.spec.tsx | 8 +-- packages/core/src/client/org/OrgSwitcher.tsx | 21 ++---- .../core/src/org/workspace-app-access.spec.ts | 21 +----- packages/core/src/org/workspace-app-access.ts | 30 ++------- packages/core/src/server/auth.spec.ts | 18 +----- .../src/components/dispatch-access.spec.tsx | 64 +------------------ .../src/components/dispatch-access.tsx | 8 --- .../src/server/lib/app-creation-store.spec.ts | 30 --------- .../src/server/lib/app-creation-store.ts | 15 +---- 10 files changed, 26 insertions(+), 191 deletions(-) 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..6eb7885f695 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,7 @@ describe("OrgSwitcher", () => { }, ); - it("hides Dispatch and its all-apps link from organization members", () => { + it("shows Dispatch and its all-apps link to organization members", () => { mocks.appLinks.mockReturnValue({ apps: [ { @@ -718,10 +718,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..73cbe2b1fe4 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"; @@ -223,7 +222,6 @@ function AppsSubmenu({ isLoading, dispatchHref, dispatchAllAppsHref, - canAccessDispatch, currentAppId, onNavigate, }: { @@ -231,20 +229,16 @@ function AppsSubmenu({ isLoading: 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" + currentAppId === "dispatch" ? null : (visibleDispatchApp ?? ({ @@ -258,9 +252,10 @@ function AppsSubmenu({ .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 +267,7 @@ function AppsSubmenu({ {isLoading ? ( ) : ( - accessibleAppsForMenu.length + appsForMenu.length )} @@ -418,7 +413,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; @@ -718,7 +712,6 @@ export function OrgSwitcher({ isLoading={appLinks.isLoading} dispatchHref={appLinks.dispatchHref} dispatchAllAppsHref={appLinks.dispatchAllAppsHref} - canAccessDispatch={canAccessDispatch} currentAppId={currentAppId} onNavigate={() => setOpen(false)} /> diff --git a/packages/core/src/org/workspace-app-access.spec.ts b/packages/core/src/org/workspace-app-access.spec.ts index acf915543ec..19671d382d2 100644 --- a/packages/core/src/org/workspace-app-access.spec.ts +++ b/packages/core/src/org/workspace-app-access.spec.ts @@ -43,29 +43,14 @@ 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 }] }); - - await expect( - isWorkspaceAppAccessAllowed("dispatch", { - email: `${role}@example.com`, - orgId: "org-1", - }), - ).resolves.toBe(true); - }, - ); - - it("denies organization members access to Dispatch", async () => { - mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] }); - + it("allows authenticated users to access Dispatch without an org-role lookup", async () => { await expect( isWorkspaceAppAccessAllowed("dispatch", { email: "member@example.com", orgId: "org-1", }), - ).resolves.toBe(false); + ).resolves.toBe(true); + expect(mocks.execute).not.toHaveBeenCalled(); }); it("allows organization members for org-visible apps", async () => { diff --git a/packages/core/src/org/workspace-app-access.ts b/packages/core/src/org/workspace-app-access.ts index 18351d2663d..80a5dd1edb0 100644 --- a/packages/core/src/org/workspace-app-access.ts +++ b/packages/core/src/org/workspace-app-access.ts @@ -191,27 +191,6 @@ async function isActiveWorkspaceOrgMember( return membership.active; } -async function isDispatchWorkspaceAppAccessAllowed( - context: WorkspaceAppAccessContext, - 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"; - } catch (error) { - console.error("[workspace-app-access] Dispatch access check failed", error); - return false; - } -} - /** * Enforce the workspace-app ACL before a hosted app's authenticated API * surface is reached. The app shell remains cacheable and anonymous; this @@ -223,12 +202,13 @@ export async function isWorkspaceAppAccessAllowed( ): Promise { const normalizedAppId = appId.trim(); const email = normalizedEmail(context.email); - if (!normalizedAppId || !email) { + if ( + !normalizedAppId || + normalizedAppId.toLowerCase() === "dispatch" || + !email + ) { return true; } - if (normalizedAppId.toLowerCase() === "dispatch") { - return isDispatchWorkspaceAppAccessAllowed(context, email); - } const hostedAccess = await hostedWorkspaceAppAccess( normalizedAppId, 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/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..ac344799122 100644 --- a/packages/dispatch/src/server/lib/app-creation-store.spec.ts +++ b/packages/dispatch/src/server/lib/app-creation-store.spec.ts @@ -101,20 +101,6 @@ vi.mock("@agent-native/core/settings", () => ({ getOrgSetting: (...args: any[]) => mocks.getOrgSetting(...args), })); -vi.mock("@agent-native/core/org", async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - isWorkspaceAppAccessAllowed: vi.fn( - async (_appId: string, context: { orgId?: string | null } = {}) => - !context.orgId || - mocks.state.orgRole === "owner" || - mocks.state.orgRole === "admin", - ), - }; -}); - vi.mock("@agent-native/core/sharing", async (importOriginal) => { const actual = await importOriginal(); @@ -296,22 +282,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( diff --git a/packages/dispatch/src/server/lib/app-creation-store.ts b/packages/dispatch/src/server/lib/app-creation-store.ts index 006b5f2ff92..7d2cea7f4b3 100644 --- a/packages/dispatch/src/server/lib/app-creation-store.ts +++ b/packages/dispatch/src/server/lib/app-creation-store.ts @@ -5,11 +5,7 @@ import { fileURLToPath } from "node:url"; import { signA2AToken } from "@agent-native/core/a2a"; import { getDbExec } from "@agent-native/core/db"; -import { - getOrgA2ASecret, - getOrgDomain, - isWorkspaceAppAccessAllowed, -} from "@agent-native/core/org"; +import { getOrgA2ASecret, getOrgDomain } from "@agent-native/core/org"; import { createBuilderProject, getBuilderBranchProjectId, @@ -1449,14 +1445,7 @@ async function filterWorkspaceAppsByAccess( continue; } if (app.isDispatch) { - if ( - await isWorkspaceAppAccessAllowed(app.id, { - email: userEmail, - orgId, - }) - ) { - visibleIds.add(app.id); - } + visibleIds.add(app.id); continue; } candidates.push(app); From ba63a9db577e4bacbed0afadd8eab5d4c03d377f Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 08:36:30 -0700 Subject: [PATCH 2/8] chore: publish branch work in packages/dispatch (15 files) --- .../src/actions/approve-dispatch-change.ts | 2 + .../create-workspace-resource-grant.ts | 2 + .../src/actions/create-workspace-resource.ts | 2 + .../src/actions/delete-workspace-resource.ts | 2 + .../grant-workspace-resources-to-app.ts | 2 + .../dispatch/src/actions/import-agent-pack.ts | 2 + packages/dispatch/src/actions/import-agent.ts | 2 + .../src/actions/reject-dispatch-change.ts | 2 + .../restore-starter-workspace-resources.ts | 2 + .../revoke-workspace-resource-grant.ts | 2 + .../actions/set-dispatch-approval-policy.ts | 2 + .../src/actions/update-workspace-resource.ts | 2 + packages/dispatch/src/server/index.ts | 4 +- .../dispatch/src/server/lib/app-roles.spec.ts | 81 +++++++++++++++++++ packages/dispatch/src/server/lib/app-roles.ts | 36 +++++++++ 15 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 packages/dispatch/src/server/lib/app-roles.spec.ts create mode 100644 packages/dispatch/src/server/lib/app-roles.ts 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-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-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/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/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/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/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/server/index.ts b/packages/dispatch/src/server/index.ts index 77d63701614..bafeb8de418 100644 --- a/packages/dispatch/src/server/index.ts +++ b/packages/dispatch/src/server/index.ts @@ -1,4 +1,3 @@ -import { defineAppRoles } from "@agent-native/core/org"; import { registerPackageActions, type NitroPluginDef, @@ -6,9 +5,8 @@ import { import { dispatchActions } from "../actions/index.js"; import type { DispatchConfig } from "../config.js"; -import { dispatchAccessDescriptor } from "../shared/app-roles.js"; -defineAppRoles(dispatchAccessDescriptor); +export { dispatchAccess } from "./lib/app-roles.js"; /** * Register dispatch's package-contributed actions on import. The framework's 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..72422ef79a3 --- /dev/null +++ b/packages/dispatch/src/server/lib/app-roles.spec.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assertAny: vi.fn(), + currentRequestUserIsOrgAdmin: vi.fn(), + getRequestOrgId: vi.fn(), + getRequestUserEmail: vi.fn(), +})); + +vi.mock("@agent-native/core/org", () => ({ + defineAppRoles: () => ({ assertAny: mocks.assertAny }), +})); + +vi.mock("@agent-native/core/server", () => ({ + currentRequestUserIsOrgAdmin: mocks.currentRequestUserIsOrgAdmin, + 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.currentRequestUserIsOrgAdmin.mockResolvedValue(false); + mocks.assertAny.mockResolvedValue("admin"); + mocks.getRequestOrgId.mockReturnValue(undefined); + mocks.getRequestUserEmail.mockReturnValue(undefined); + }); + + 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.assertAny).toHaveBeenCalledWith(["admin"], { + orgId: "org-1", + userEmail: "member@example.test", + }); + }); + + it("allows an organization admin without an app-role assignment", async () => { + mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(true); + + 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.currentRequestUserIsOrgAdmin).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..b87c07d10cc --- /dev/null +++ b/packages/dispatch/src/server/lib/app-roles.ts @@ -0,0 +1,36 @@ +import type { ActionRunContext } from "@agent-native/core/action"; +import { defineAppRoles } from "@agent-native/core/org"; +import { + currentRequestUserIsOrgAdmin, + getRequestOrgId, + getRequestUserEmail, +} from "@agent-native/core/server"; +import { ForbiddenError } from "@agent-native/core/sharing"; + +import { dispatchAccessDescriptor } from "../../shared/app-roles.js"; + +export const 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; + if (await currentRequestUserIsOrgAdmin(orgId)) return; + await dispatchAccess.assertAny(["admin"], { + userEmail: email, + orgId, + }); +} From 5648f39f0b5daae9ab869a32bd3bfc4dd8a63914 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 08:46:33 -0700 Subject: [PATCH 3/8] chore: publish branch work in packages/dispatch (1 files) --- packages/dispatch/src/actions/list-workspace-connections.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/dispatch/src/actions/list-workspace-connections.ts b/packages/dispatch/src/actions/list-workspace-connections.ts index 225c698a768..1afb68428c0 100644 --- a/packages/dispatch/src/actions/list-workspace-connections.ts +++ b/packages/dispatch/src/actions/list-workspace-connections.ts @@ -19,9 +19,10 @@ import { listWorkspaceConnectionsForUser, summarizeWorkspaceConnectionProviderReadiness, } from "@agent-native/core/workspace-connections"; -import { dispatchActions } from "@agent-native/dispatch/actions"; import { z } from "zod"; +import { dispatchActions } from "./index.js"; + const httpBoolean = z.preprocess((value) => { if (typeof value !== "string") return value; const normalized = value.trim().toLowerCase(); From 51327e82cff2c23d54967657c48bf777174d4a7b Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 08:53:42 -0700 Subject: [PATCH 4/8] chore: publish branch work in packages/dispatch (3 files) --- .../dispatch/src/actions/list-workspace-connections.ts | 3 +-- packages/dispatch/src/server/index.ts | 4 +++- packages/dispatch/src/server/lib/app-roles.ts | 10 +++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/dispatch/src/actions/list-workspace-connections.ts b/packages/dispatch/src/actions/list-workspace-connections.ts index 1afb68428c0..225c698a768 100644 --- a/packages/dispatch/src/actions/list-workspace-connections.ts +++ b/packages/dispatch/src/actions/list-workspace-connections.ts @@ -19,10 +19,9 @@ import { listWorkspaceConnectionsForUser, summarizeWorkspaceConnectionProviderReadiness, } from "@agent-native/core/workspace-connections"; +import { dispatchActions } from "@agent-native/dispatch/actions"; import { z } from "zod"; -import { dispatchActions } from "./index.js"; - const httpBoolean = z.preprocess((value) => { if (typeof value !== "string") return value; const normalized = value.trim().toLowerCase(); diff --git a/packages/dispatch/src/server/index.ts b/packages/dispatch/src/server/index.ts index bafeb8de418..77d63701614 100644 --- a/packages/dispatch/src/server/index.ts +++ b/packages/dispatch/src/server/index.ts @@ -1,3 +1,4 @@ +import { defineAppRoles } from "@agent-native/core/org"; import { registerPackageActions, type NitroPluginDef, @@ -5,8 +6,9 @@ import { import { dispatchActions } from "../actions/index.js"; import type { DispatchConfig } from "../config.js"; +import { dispatchAccessDescriptor } from "../shared/app-roles.js"; -export { dispatchAccess } from "./lib/app-roles.js"; +defineAppRoles(dispatchAccessDescriptor); /** * Register dispatch's package-contributed actions on import. The framework's diff --git a/packages/dispatch/src/server/lib/app-roles.ts b/packages/dispatch/src/server/lib/app-roles.ts index b87c07d10cc..ed79ad5c9a4 100644 --- a/packages/dispatch/src/server/lib/app-roles.ts +++ b/packages/dispatch/src/server/lib/app-roles.ts @@ -1,5 +1,5 @@ import type { ActionRunContext } from "@agent-native/core/action"; -import { defineAppRoles } from "@agent-native/core/org"; +import { defineAppRoles, type AppRoles } from "@agent-native/core/org"; import { currentRequestUserIsOrgAdmin, getRequestOrgId, @@ -9,7 +9,11 @@ import { ForbiddenError } from "@agent-native/core/sharing"; import { dispatchAccessDescriptor } from "../../shared/app-roles.js"; -export const dispatchAccess = defineAppRoles(dispatchAccessDescriptor); +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 @@ -29,7 +33,7 @@ export async function authorizeDispatchAdmin( } if (!orgId?.trim()) return; if (await currentRequestUserIsOrgAdmin(orgId)) return; - await dispatchAccess.assertAny(["admin"], { + await getDispatchAccess().assertAny(["admin"], { userEmail: email, orgId, }); From 95d05533ed8679a061b61479b0af8f1c288e3036 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 09:15:47 -0700 Subject: [PATCH 5/8] chore: publish branch work in packages/dispatch (6 files) --- .../src/actions/create-pylon-ticket.ts | 2 + .../dispatch-admin-authorization.spec.ts | 85 +++++++++++++++++++ .../src/actions/send-platform-message.spec.ts | 4 + .../src/actions/send-platform-message.ts | 2 + .../dispatch/src/server/lib/app-roles.spec.ts | 34 ++++++-- packages/dispatch/src/server/lib/app-roles.ts | 19 ++++- 6 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts 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/dispatch-admin-authorization.spec.ts b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts new file mode 100644 index 00000000000..01c7d64f8a6 --- /dev/null +++ b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + assertAny: vi.fn(), + getDestinationById: vi.fn(), + resolveSecret: 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", () => ({ + getDestinationById: mocks.getDestinationById, + recordAudit: vi.fn(), +})); + +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 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(); + }); +}); 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/server/lib/app-roles.spec.ts b/packages/dispatch/src/server/lib/app-roles.spec.ts index 72422ef79a3..70b5df70341 100644 --- a/packages/dispatch/src/server/lib/app-roles.spec.ts +++ b/packages/dispatch/src/server/lib/app-roles.spec.ts @@ -2,17 +2,18 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ assertAny: vi.fn(), - currentRequestUserIsOrgAdmin: vi.fn(), getRequestOrgId: vi.fn(), getRequestUserEmail: vi.fn(), + validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(), })); vi.mock("@agent-native/core/org", () => ({ defineAppRoles: () => ({ assertAny: mocks.assertAny }), + validateFederatedOrganizationMembershipForCurrentRequest: + mocks.validateFederatedOrganizationMembershipForCurrentRequest, })); vi.mock("@agent-native/core/server", () => ({ - currentRequestUserIsOrgAdmin: mocks.currentRequestUserIsOrgAdmin, getRequestOrgId: mocks.getRequestOrgId, getRequestUserEmail: mocks.getRequestUserEmail, })); @@ -30,7 +31,9 @@ const context = { describe("authorizeDispatchAdmin", () => { beforeEach(() => { vi.clearAllMocks(); - mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(false); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockResolvedValue( + { active: true, role: "member" }, + ); mocks.assertAny.mockResolvedValue("admin"); mocks.getRequestOrgId.mockReturnValue(undefined); mocks.getRequestUserEmail.mockReturnValue(undefined); @@ -44,6 +47,12 @@ describe("authorizeDispatchAdmin", () => { 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", @@ -51,12 +60,25 @@ describe("authorizeDispatchAdmin", () => { }); it("allows an organization admin without an app-role assignment", async () => { - mocks.currentRequestUserIsOrgAdmin.mockResolvedValue(true); + 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 a member with the Dispatch admin role", async () => { await expect(authorizeDispatchAdmin({}, context)).resolves.toBeUndefined(); expect(mocks.assertAny).toHaveBeenCalledWith(["admin"], { @@ -69,7 +91,9 @@ describe("authorizeDispatchAdmin", () => { await expect( authorizeDispatchAdmin({}, { ...context, orgId: null }), ).resolves.toBeUndefined(); - expect(mocks.currentRequestUserIsOrgAdmin).not.toHaveBeenCalled(); + expect( + mocks.validateFederatedOrganizationMembershipForCurrentRequest, + ).not.toHaveBeenCalled(); expect(mocks.assertAny).not.toHaveBeenCalled(); }); diff --git a/packages/dispatch/src/server/lib/app-roles.ts b/packages/dispatch/src/server/lib/app-roles.ts index ed79ad5c9a4..397cc296f8e 100644 --- a/packages/dispatch/src/server/lib/app-roles.ts +++ b/packages/dispatch/src/server/lib/app-roles.ts @@ -1,7 +1,10 @@ import type { ActionRunContext } from "@agent-native/core/action"; -import { defineAppRoles, type AppRoles } from "@agent-native/core/org"; import { - currentRequestUserIsOrgAdmin, + defineAppRoles, + type AppRoles, + validateFederatedOrganizationMembershipForCurrentRequest, +} from "@agent-native/core/org"; +import { getRequestOrgId, getRequestUserEmail, } from "@agent-native/core/server"; @@ -32,7 +35,17 @@ export async function authorizeDispatchAdmin( ); } if (!orgId?.trim()) return; - if (await currentRequestUserIsOrgAdmin(orgId)) return; + const membership = + await validateFederatedOrganizationMembershipForCurrentRequest({ + orgId, + email, + }); + 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, From d0ae491152a04ec6f41070e6a2c210f045bc05ef Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 09:59:27 -0700 Subject: [PATCH 6/8] chore: publish branch work in packages/core, packages/dispatch (11 files) --- packages/core/src/org/membership.spec.ts | 8 ++ packages/core/src/org/membership.ts | 2 +- .../core/src/org/workspace-app-access.spec.ts | 40 +++++++++- packages/core/src/org/workspace-app-access.ts | 30 +++++-- .../src/actions/apply-dream-proposal.ts | 6 +- .../src/actions/delete-destination.ts | 2 + .../dispatch-admin-authorization.spec.ts | 29 +++++++ .../src/actions/reject-dream-proposal.ts | 6 +- .../src/actions/upsert-destination.ts | 2 + .../src/server/lib/dreams-store.spec.ts | 78 +++++++++++++++++++ .../dispatch/src/server/lib/dreams-store.ts | 33 ++++++++ 11 files changed, 226 insertions(+), 10 deletions(-) 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 19671d382d2..cf8c1ad22c9 100644 --- a/packages/core/src/org/workspace-app-access.spec.ts +++ b/packages/core/src/org/workspace-app-access.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), includeUser: vi.fn(), + validateFederatedOrganizationMembershipForCurrentRequest: vi.fn(), })); vi.mock("../db/client.js", () => ({ @@ -14,6 +15,11 @@ 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", () => { @@ -22,6 +28,7 @@ describe("isWorkspaceAppAccessAllowed", () => { vi.unstubAllGlobals(); mocks.execute.mockReset(); mocks.includeUser.mockReset(); + mocks.validateFederatedOrganizationMembershipForCurrentRequest.mockReset(); }); it("allows the recorded owner in the app organization", async () => { @@ -43,14 +50,43 @@ describe("isWorkspaceAppAccessAllowed", () => { ).resolves.toBe(true); }); - it("allows authenticated users to access Dispatch without an org-role lookup", async () => { + it("allows active organization members to access Dispatch", async () => { + mocks.execute.mockResolvedValueOnce({ rows: [{ role: "member" }] }); + await expect( isWorkspaceAppAccessAllowed("dispatch", { email: "member@example.com", orgId: "org-1", }), ).resolves.toBe(true); - expect(mocks.execute).not.toHaveBeenCalled(); + }); + + 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("allows organization members for org-visible apps", async () => { diff --git a/packages/core/src/org/workspace-app-access.ts b/packages/core/src/org/workspace-app-access.ts index 80a5dd1edb0..b1016444f4c 100644 --- a/packages/core/src/org/workspace-app-access.ts +++ b/packages/core/src/org/workspace-app-access.ts @@ -191,6 +191,27 @@ async function isActiveWorkspaceOrgMember( return membership.active; } +async function isDispatchWorkspaceAppAccessAllowed( + context: WorkspaceAppAccessContext, + email: string, +): Promise { + const orgId = context.orgId?.trim() || null; + if (!orgId) return true; + + try { + const member = await loadWorkspaceOrgMember(getDbExec(), orgId, email); + // 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)) return true; + console.error("[workspace-app-access] Dispatch access check failed", error); + return false; + } +} + /** * Enforce the workspace-app ACL before a hosted app's authenticated API * surface is reached. The app shell remains cacheable and anonymous; this @@ -202,13 +223,12 @@ export async function isWorkspaceAppAccessAllowed( ): Promise { const normalizedAppId = appId.trim(); const email = normalizedEmail(context.email); - if ( - !normalizedAppId || - normalizedAppId.toLowerCase() === "dispatch" || - !email - ) { + if (!normalizedAppId || !email) { return true; } + if (normalizedAppId.toLowerCase() === "dispatch") { + return isDispatchWorkspaceAppAccessAllowed(context, email); + } const hostedAccess = await hostedWorkspaceAppAccess( normalizedAppId, 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/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/dispatch-admin-authorization.spec.ts b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts index 01c7d64f8a6..aee1db3afdf 100644 --- a/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts +++ b/packages/dispatch/src/actions/dispatch-admin-authorization.spec.ts @@ -2,8 +2,10 @@ 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(), })); @@ -25,8 +27,10 @@ vi.mock("@agent-native/core/integrations", () => ({ })); 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"; @@ -34,6 +38,8 @@ 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, @@ -82,4 +88,27 @@ describe("Dispatch admin authorization", () => { 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/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/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/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..d3e5a4a34fe 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, @@ -2394,6 +2397,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[]; From 9936c8d43b8f3aba4f7491bae99e98f37008f5c6 Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 10:43:25 -0700 Subject: [PATCH 7/8] chore: publish branch work in packages/core, packages/dispatch (13 files) --- packages/core/src/org/index.ts | 6 ++- .../core/src/org/workspace-app-access.spec.ts | 31 +++++++++++++++ packages/core/src/org/workspace-app-access.ts | 25 +++++++++++- .../dispatch/src/actions/ensure-dream-job.ts | 2 + .../src/actions/set-dream-settings.spec.ts | 5 +++ .../src/actions/set-dream-settings.ts | 2 + .../src/server/lib/app-creation-store.spec.ts | 29 ++++++++++++++ .../src/server/lib/app-creation-store.ts | 15 ++++++- .../dispatch/src/server/lib/app-roles.spec.ts | 15 +++++++ packages/dispatch/src/server/lib/app-roles.ts | 24 +++++++++--- .../src/server/lib/approval-fencing.spec.ts | 39 +++++++++++++++++++ .../dispatch/src/server/lib/dispatch-store.ts | 5 ++- .../dispatch/src/server/lib/dreams-store.ts | 5 ++- 13 files changed, 191 insertions(+), 12 deletions(-) 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/workspace-app-access.spec.ts b/packages/core/src/org/workspace-app-access.spec.ts index cf8c1ad22c9..30da3958f24 100644 --- a/packages/core/src/org/workspace-app-access.spec.ts +++ b/packages/core/src/org/workspace-app-access.spec.ts @@ -89,6 +89,37 @@ describe("isWorkspaceAppAccessAllowed", () => { }); }); + it("keeps standalone Dispatch available when its org schema is absent", async () => { + vi.stubEnv("AGENT_NATIVE_APP_ID", "dispatch"); + 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"); + mocks.execute.mockRejectedValueOnce( + new Error('relation "org_members" does not exist'), + ); + + await expect( + isWorkspaceAppAccessAllowed("dispatch", { + email: "member@example.com", + orgId: "org-1", + }), + ).resolves.toBe(false); + }); + it("allows organization members for org-visible apps", async () => { mocks.execute .mockResolvedValueOnce({ diff --git a/packages/core/src/org/workspace-app-access.ts b/packages/core/src/org/workspace-app-access.ts index b1016444f4c..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) = ? @@ -206,7 +222,12 @@ async function isDispatchWorkspaceAppAccessAllowed( member && (await isActiveWorkspaceOrgMember(member, orgId, email)), ); } catch (error) { - if (isMissingOrganizationTableError(error)) return true; + if ( + isMissingOrganizationTableError(error) && + isStandaloneDispatchRuntime() + ) { + return true; + } console.error("[workspace-app-access] Dispatch access check failed", error); return false; } 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/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/server/lib/app-creation-store.spec.ts b/packages/dispatch/src/server/lib/app-creation-store.spec.ts index ac344799122..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: {}, @@ -101,6 +102,16 @@ vi.mock("@agent-native/core/settings", () => ({ getOrgSetting: (...args: any[]) => mocks.getOrgSetting(...args), })); +vi.mock("@agent-native/core/org", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isWorkspaceAppAccessAllowed: (...args: any[]) => + mocks.isWorkspaceAppAccessAllowed(...args), + }; +}); + vi.mock("@agent-native/core/sharing", async (importOriginal) => { const actual = await importOriginal(); @@ -144,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) => { @@ -568,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 7d2cea7f4b3..5649ecc6724 100644 --- a/packages/dispatch/src/server/lib/app-creation-store.ts +++ b/packages/dispatch/src/server/lib/app-creation-store.ts @@ -5,7 +5,11 @@ import { fileURLToPath } from "node:url"; import { signA2AToken } from "@agent-native/core/a2a"; import { getDbExec } from "@agent-native/core/db"; -import { getOrgA2ASecret, getOrgDomain } from "@agent-native/core/org"; +import { + getOrgA2ASecret, + getOrgDomain, + isWorkspaceAppAccessAllowed, +} from "@agent-native/core/org"; import { createBuilderProject, getBuilderBranchProjectId, @@ -1445,7 +1449,14 @@ async function filterWorkspaceAppsByAccess( continue; } if (app.isDispatch) { - visibleIds.add(app.id); + if ( + await isWorkspaceAppAccessAllowed("dispatch", { + email: userEmail, + orgId, + }) + ) { + visibleIds.add(app.id); + } continue; } candidates.push(app); diff --git a/packages/dispatch/src/server/lib/app-roles.spec.ts b/packages/dispatch/src/server/lib/app-roles.spec.ts index 70b5df70341..f877d9a7a12 100644 --- a/packages/dispatch/src/server/lib/app-roles.spec.ts +++ b/packages/dispatch/src/server/lib/app-roles.spec.ts @@ -4,11 +4,15 @@ 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, })); @@ -37,6 +41,7 @@ describe("authorizeDispatchAdmin", () => { 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 () => { @@ -79,6 +84,16 @@ describe("authorizeDispatchAdmin", () => { 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"], { diff --git a/packages/dispatch/src/server/lib/app-roles.ts b/packages/dispatch/src/server/lib/app-roles.ts index 397cc296f8e..5c4dbbcce35 100644 --- a/packages/dispatch/src/server/lib/app-roles.ts +++ b/packages/dispatch/src/server/lib/app-roles.ts @@ -1,6 +1,8 @@ import type { ActionRunContext } from "@agent-native/core/action"; import { defineAppRoles, + isMissingOrganizationTableError, + isStandaloneDispatchRuntime, type AppRoles, validateFederatedOrganizationMembershipForCurrentRequest, } from "@agent-native/core/org"; @@ -35,11 +37,23 @@ export async function authorizeDispatchAdmin( ); } if (!orgId?.trim()) return; - const membership = - await validateFederatedOrganizationMembershipForCurrentRequest({ - orgId, - email, - }); + 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.", 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.ts b/packages/dispatch/src/server/lib/dreams-store.ts index d3e5a4a34fe..30b3dde897f 100644 --- a/packages/dispatch/src/server/lib/dreams-store.ts +++ b/packages/dispatch/src/server/lib/dreams-store.ts @@ -447,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 { From 6a25fe98ff662467cd6ca5fc75bcd5207f55d7bc Mon Sep 17 00:00:00 2001 From: Steve Sewell Date: Wed, 9 Sep 2026 11:20:31 -0700 Subject: [PATCH 8/8] chore: publish branch work in packages/core (5 files) --- .../core/src/client/org/OrgSwitcher.spec.tsx | 50 +++++++++++++++++++ packages/core/src/client/org/OrgSwitcher.tsx | 20 +++++--- packages/core/src/org/federation.spec.ts | 30 +++++++++++ packages/core/src/org/federation.ts | 17 ++++--- .../core/src/org/workspace-app-access.spec.ts | 5 ++ 5 files changed, 107 insertions(+), 15 deletions(-) diff --git a/packages/core/src/client/org/OrgSwitcher.spec.tsx b/packages/core/src/client/org/OrgSwitcher.spec.tsx index 6eb7885f695..3603c5b9c12 100644 --- a/packages/core/src/client/org/OrgSwitcher.spec.tsx +++ b/packages/core/src/client/org/OrgSwitcher.spec.tsx @@ -658,6 +658,56 @@ describe("OrgSwitcher", () => { }, ); + 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: [ diff --git a/packages/core/src/client/org/OrgSwitcher.tsx b/packages/core/src/client/org/OrgSwitcher.tsx index 73cbe2b1fe4..58c936cf9e9 100644 --- a/packages/core/src/client/org/OrgSwitcher.tsx +++ b/packages/core/src/client/org/OrgSwitcher.tsx @@ -220,6 +220,7 @@ function AppMenuLink({ function AppsSubmenu({ apps, isLoading, + isWorkspace, dispatchHref, dispatchAllAppsHref, currentAppId, @@ -227,6 +228,7 @@ function AppsSubmenu({ }: { apps: OrgSwitcherAppLink[]; isLoading: boolean; + isWorkspace: boolean; dispatchHref: string; dispatchAllAppsHref: string; currentAppId?: string; @@ -237,17 +239,20 @@ function AppsSubmenu({ : apps; const { links, overflowCount } = visibleOrgAppLinks(appsForMenu); const visibleDispatchApp = links.find((app) => app.isDispatch); - const dispatchApp = - 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); @@ -710,6 +715,7 @@ export function OrgSwitcher({ { ); }); + 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/workspace-app-access.spec.ts b/packages/core/src/org/workspace-app-access.spec.ts index 30da3958f24..9eb16c1508c 100644 --- a/packages/core/src/org/workspace-app-access.spec.ts +++ b/packages/core/src/org/workspace-app-access.spec.ts @@ -1,5 +1,7 @@ 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(), @@ -25,6 +27,7 @@ import { isWorkspaceAppAccessAllowed } from "./workspace-app-access.js"; describe("isWorkspaceAppAccessAllowed", () => { afterEach(() => { vi.unstubAllEnvs(); + resetAppConfigForTests(); vi.unstubAllGlobals(); mocks.execute.mockReset(); mocks.includeUser.mockReset(); @@ -91,6 +94,7 @@ describe("isWorkspaceAppAccessAllowed", () => { 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( @@ -108,6 +112,7 @@ describe("isWorkspaceAppAccessAllowed", () => { 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'), );