Skip to content

Commit 0787f0c

Browse files
committed
Merge remote-tracking branch 'origin/feat/dashboard-agent-flows' into feat/dashboard-agent-ui
2 parents 57f0762 + 8fbbcf0 commit 0787f0c

2 files changed

Lines changed: 70 additions & 8 deletions

File tree

apps/webapp/app/routes/api.v1.orgs.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,33 @@ export const loader = createLoaderPATApiRoute(
4040
}
4141
);
4242

43-
// No org exists yet, so there is nothing to scope the gate to; any authenticated user can create
44-
// an org and becomes its ADMIN. The gate is still declared so a narrowly-capped delegated token
45-
// (which cannot `manage`) is refused rather than inheriting its user's full reach.
43+
// No org exists yet, so there is nothing to scope a route-level gate to; any authenticated user
44+
// can create an org and becomes its ADMIN. A narrowly-capped delegated token (which cannot
45+
// `manage`) is still refused rather than inheriting its user's full reach — but only for
46+
// user-actor tokens, so an ordinary PAT is unaffected.
4647
export const action = createActionPATApiRoute(
4748
{
4849
method: "POST",
4950
body: CreateOrgRequestBody,
50-
authorization: { action: "manage", resource: () => ({ type: "organization" }) },
5151
},
52-
async ({ body, authentication }) => {
52+
async ({ body, authentication, ability }) => {
5353
if (env.ORG_CREATION_API_ENABLED !== "1") {
5454
return json({ error: "Not found" }, { status: 404 });
5555
}
5656

57+
// After the env gate: an install with the API disabled should 404, not 403.
58+
if (authentication.userActor && !ability.can("manage", { type: "organization" })) {
59+
return json(
60+
{
61+
error: "Unauthorized",
62+
code: "unauthorized",
63+
param: "access_token",
64+
type: "authorization",
65+
},
66+
{ status: 403 }
67+
);
68+
}
69+
5770
// Mirror the dashboard: stash companyUrl/companySize as onboarding data and
5871
// derive the org avatar from the company domain's favicon.
5972
const onboardingData: Record<string, string> = {};

apps/webapp/test/contextlessPatRoutes.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ const mocks = vi.hoisted(() => ({
1313
authenticatePat: vi.fn(),
1414
createOrganization: vi.fn(),
1515
findManyProjects: vi.fn(),
16+
env: { SESSION_SECRET: "test-session-secret", ORG_CREATION_API_ENABLED: "1" } as {
17+
SESSION_SECRET: string;
18+
ORG_CREATION_API_ENABLED?: string;
19+
},
1620
}));
1721

1822
vi.mock("~/services/rbac.server", () => ({
@@ -25,9 +29,7 @@ vi.mock("~/db.server", () => ({
2529
prisma: { project: { findMany: mocks.findManyProjects } },
2630
$replica: {},
2731
}));
28-
vi.mock("~/env.server", () => ({
29-
env: { SESSION_SECRET: "test-session-secret", ORG_CREATION_API_ENABLED: "1" },
30-
}));
32+
vi.mock("~/env.server", () => ({ env: mocks.env }));
3133
vi.mock("~/models/organization.server", () => ({ createOrganization: mocks.createOrganization }));
3234
vi.mock("~/services/personalAccessToken.server", () => ({
3335
updateLastAccessedAtIfStale: vi.fn(),
@@ -77,6 +79,30 @@ async function createOrg(cap: string[]): Promise<{ status: number; body: any }>
7779
return { status: response.status, body: await response.json() };
7880
}
7981

82+
// An ordinary PAT, paired with an ability that denies everything. Nothing on this route may
83+
// consult it — the route has no org to scope a gate to, and on cloud the plugin returns a
84+
// deny-shaped ability when there is no org context.
85+
async function createOrgWithPat(): Promise<{ status: number; body: any }> {
86+
mocks.authenticatePat.mockImplementation(async () => ({
87+
ok: true,
88+
userId: USER_ID,
89+
tokenId: "pat_1",
90+
lastAccessedAt: new Date(),
91+
ability: { can: () => false, canSuper: () => false },
92+
}));
93+
94+
const response = await action({
95+
request: new Request("https://api.trigger.dev/api/v1/orgs", {
96+
method: "POST",
97+
headers: { Authorization: "Bearer tr_pat_1234", "Content-Type": "application/json" },
98+
body: JSON.stringify({ title: "New Org" }),
99+
}),
100+
params: {},
101+
context: {},
102+
} as any);
103+
return { status: response.status, body: await response.json() };
104+
}
105+
80106
const AGENT_ENVIRONMENT_ID = "env_dev";
81107

82108
async function listProjects(): Promise<{ status: number; body: any }> {
@@ -146,6 +172,29 @@ describe("creating an organization over the API", () => {
146172
expect(mocks.createOrganization).not.toHaveBeenCalled();
147173
});
148174

175+
it("admits an ordinary PAT without consulting its ability", async () => {
176+
const result = await createOrgWithPat();
177+
178+
expect(result.status).toBe(201);
179+
expect(result.body.slug).toBe("new-org");
180+
});
181+
182+
// The env gate runs before the capability gate, so an install with the API disabled tells
183+
// every caller the same thing: the route does not exist. A capped token must not learn from a
184+
// 403 that it would have been the only thing standing in its way.
185+
it("hides the route from a capped token when the API is disabled", async () => {
186+
mocks.env.ORG_CREATION_API_ENABLED = undefined;
187+
188+
try {
189+
const result = await createOrg(["read:all"]);
190+
191+
expect(result.status).toBe(404);
192+
expect(mocks.createOrganization).not.toHaveBeenCalled();
193+
} finally {
194+
mocks.env.ORG_CREATION_API_ENABLED = "1";
195+
}
196+
});
197+
149198
it("still admits a token that carries the universal grant", async () => {
150199
const result = await createOrg(["admin"]);
151200

0 commit comments

Comments
 (0)