Skip to content

Commit f2fbca9

Browse files
committed
fix(webapp): address located environments by type, report archived scopes
1 parent 4b21959 commit f2fbca9

2 files changed

Lines changed: 126 additions & 18 deletions

File tree

apps/webapp/app/services/locateAgentObject.server.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import { boundedIn, type RuntimeEnvironmentType } from "@trigger.dev/database";
99
import { ErrorId } from "@trigger.dev/core/v3/isomorphic";
1010
import { $replica } from "~/db.server";
11+
import { dashboardAgentEnvironmentAddress } from "~/services/dashboardAgentEnvironmentAddress.server";
1112
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
1213
import { runStore } from "~/v3/runStore.server";
1314

@@ -99,13 +100,13 @@ async function scopesForEnvironments(
99100
where: {
100101
id: { in: boundedIn(environmentIds) },
101102
organizationId,
102-
archivedAt: null,
103103
project: { deletedAt: null },
104104
},
105105
select: {
106106
slug: true,
107107
type: true,
108108
branchName: true,
109+
archivedAt: true,
109110
orgMember: { select: { userId: true } },
110111
project: { select: { externalRef: true, name: true } },
111112
},
@@ -114,10 +115,14 @@ async function scopesForEnvironments(
114115
return environments.map((environment) => ({
115116
projectRef: environment.project.externalRef,
116117
projectName: environment.project.name,
117-
environmentName: environment.slug,
118+
// Name the API routes address, not the dashboard slug: a branch child's slug is compound.
119+
environmentName:
120+
dashboardAgentEnvironmentAddress(environment).environmentName ?? environment.slug,
118121
environmentType: environment.type,
119122
...(environment.branchName ? { branchName: environment.branchName } : {}),
120-
// dev is per-user: another member's dev environment exists but can't be acted in.
121-
targetable: environment.type !== "DEVELOPMENT" || environment.orgMember?.userId === userId,
123+
// dev is per-user and an archived env is frozen; both exist, so both are still reported.
124+
targetable:
125+
!environment.archivedAt &&
126+
(environment.type !== "DEVELOPMENT" || environment.orgMember?.userId === userId),
122127
}));
123128
}

apps/webapp/test/locateAgentObject.test.ts

Lines changed: 117 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ vi.mock("~/services/personalAccessToken.server", () => ({
4747
assertSourcePatActive: async () => true,
4848
updateLastAccessedAtIfStale: vi.fn(),
4949
resolveAndRecheckUserActorClaims: vi.fn(),
50+
// A real PAT authenticates a user but carries no actor claims, so it never reaches the locator.
51+
isPersonalAccessToken: (token: string) => token.startsWith("tr_pat_"),
52+
authenticateApiRequestWithPersonalAccessToken: async () => ({ userId: "user_with_a_pat" }),
5053
}));
5154
vi.mock("~/services/authTelemetry.server", () => ({
5255
authenticateBearerWithTelemetry: vi.fn(),
@@ -81,7 +84,11 @@ function suffix() {
8184
return Math.random().toString(36).slice(2, 10);
8285
}
8386

84-
/** An org with two projects, each with prod plus a dev environment per member. */
87+
/**
88+
* An org with two projects. Each carries the whole environment family the locator has to name:
89+
* prod, staging, a dev root per member, a preview root, and branch children of both branchable
90+
* types — plus one archived preview branch.
91+
*/
8592
async function seedOrg(prisma: PrismaClient) {
8693
const slug = `locate_${suffix()}`;
8794
const member = await prisma.user.create({
@@ -111,8 +118,13 @@ async function seedOrg(prisma: PrismaClient) {
111118

112119
const environmentFor = (
113120
envSlug: string,
114-
type: "PRODUCTION" | "DEVELOPMENT",
115-
orgMemberId?: string
121+
type: "PRODUCTION" | "STAGING" | "PREVIEW" | "DEVELOPMENT",
122+
extra?: {
123+
orgMemberId?: string;
124+
parentEnvironmentId?: string;
125+
branchName?: string;
126+
archivedAt?: Date;
127+
}
116128
) =>
117129
prisma.runtimeEnvironment.create({
118130
data: {
@@ -123,16 +135,36 @@ async function seedOrg(prisma: PrismaClient) {
123135
apiKey: `tr_${envSlug}_${projectSlug}_${suffix()}`,
124136
pkApiKey: `pk_${envSlug}_${projectSlug}_${suffix()}`,
125137
shortcode: `${envSlug}${suffix()}`,
126-
...(orgMemberId ? { orgMemberId } : {}),
138+
...extra,
127139
},
128140
select: { id: true, slug: true },
129141
});
130142

143+
const ownDev = await environmentFor("dev", "DEVELOPMENT", { orgMemberId: memberOf.id });
144+
const previewRoot = await environmentFor("preview", "PREVIEW");
145+
131146
return {
132147
project,
133148
prod: await environmentFor("prod", "PRODUCTION"),
134-
ownDev: await environmentFor("dev", "DEVELOPMENT", memberOf.id),
135-
otherDev: await environmentFor("dev", "DEVELOPMENT", otherMemberOf.id),
149+
// The dashboard slug for staging is "stg"; the API name is "staging".
150+
staging: await environmentFor("stg", "STAGING"),
151+
ownDev,
152+
otherDev: await environmentFor("dev", "DEVELOPMENT", { orgMemberId: otherMemberOf.id }),
153+
previewRoot,
154+
previewBranch: await environmentFor("preview-feat-a", "PREVIEW", {
155+
parentEnvironmentId: previewRoot.id,
156+
branchName: "feat/a",
157+
}),
158+
archivedPreviewBranch: await environmentFor("preview-feat-old", "PREVIEW", {
159+
parentEnvironmentId: previewRoot.id,
160+
branchName: "feat/old",
161+
archivedAt: new Date(),
162+
}),
163+
devBranch: await environmentFor("dev-feat-a", "DEVELOPMENT", {
164+
orgMemberId: memberOf.id,
165+
parentEnvironmentId: ownDev.id,
166+
branchName: "feat/a",
167+
}),
136168
};
137169
}
138170

@@ -238,15 +270,18 @@ async function callLoader(opts: {
238270
id: string;
239271
userId?: string;
240272
organizationId?: string;
273+
bearer?: string;
241274
}) {
242-
const token = opts.userId
243-
? await signUserActorToken(SESSION_SECRET, {
244-
userId: opts.userId,
245-
client: "dashboard-agent",
246-
cap: ["read:runs"],
247-
...(opts.organizationId ? { organizationId: opts.organizationId } : {}),
248-
} as any)
249-
: undefined;
275+
const token = opts.bearer
276+
? opts.bearer
277+
: opts.userId
278+
? await signUserActorToken(SESSION_SECRET, {
279+
userId: opts.userId,
280+
client: "dashboard-agent",
281+
cap: ["read:runs"],
282+
...(opts.organizationId ? { organizationId: opts.organizationId } : {}),
283+
} as any)
284+
: undefined;
250285

251286
const response = await loader({
252287
request: new Request(`https://api.trigger.dev/api/v1/locate/${opts.kind}/${opts.id}`, {
@@ -305,6 +340,68 @@ containerTest(
305340
const ownDev = await callLoader({ kind: "run", id: ownDevRun.friendlyId, ...caller });
306341
expect(ownDev.body.scopes[0].targetable).toBe(true);
307342

343+
// Staging's dashboard slug is "stg"; the address the agent has to use is "staging".
344+
const stagingRun = await createRun(
345+
prisma,
346+
scopeOf(orgA, orgA.current),
347+
orgA.current.staging.id
348+
);
349+
const staging = await callLoader({ kind: "run", id: stagingRun.friendlyId, ...caller });
350+
expect(staging.body.scopes[0]).toMatchObject({
351+
environmentName: "staging",
352+
environmentType: "STAGING",
353+
targetable: true,
354+
});
355+
expect(staging.body.scopes[0].branchName).toBeUndefined();
356+
357+
// A preview branch child: the name is the family, the branch is the rest of the address.
358+
const previewBranchRun = await createRun(
359+
prisma,
360+
scopeOf(orgA, orgA.current),
361+
orgA.current.previewBranch.id
362+
);
363+
const previewBranch = await callLoader({
364+
kind: "run",
365+
id: previewBranchRun.friendlyId,
366+
...caller,
367+
});
368+
expect(previewBranch.body.scopes[0]).toEqual({
369+
projectRef: orgA.current.project.externalRef,
370+
projectName: orgA.current.project.name,
371+
environmentName: "preview",
372+
environmentType: "PREVIEW",
373+
branchName: "feat/a",
374+
targetable: true,
375+
});
376+
377+
// A dev branch child of the caller's own dev root.
378+
const devBranchRun = await createRun(
379+
prisma,
380+
scopeOf(orgA, orgA.current),
381+
orgA.current.devBranch.id
382+
);
383+
const devBranch = await callLoader({ kind: "run", id: devBranchRun.friendlyId, ...caller });
384+
expect(devBranch.body.scopes[0]).toMatchObject({
385+
environmentName: "dev",
386+
environmentType: "DEVELOPMENT",
387+
branchName: "feat/a",
388+
targetable: true,
389+
});
390+
391+
// An archived branch still exists, so the run is located, it just can't be acted in.
392+
const archivedRun = await createRun(
393+
prisma,
394+
scopeOf(orgA, orgA.current),
395+
orgA.current.archivedPreviewBranch.id
396+
);
397+
const archived = await callLoader({ kind: "run", id: archivedRun.friendlyId, ...caller });
398+
expect(archived.body.found).toBe(true);
399+
expect(archived.body.scopes[0]).toMatchObject({
400+
environmentName: "preview",
401+
branchName: "feat/old",
402+
targetable: false,
403+
});
404+
308405
// A run in another organization must not even be confirmed to exist.
309406
const foreignRun = await createRun(prisma, scopeOf(orgB, orgB.current), orgB.current.prod.id);
310407
const foreign = await callLoader({ kind: "run", id: foreignRun.friendlyId, ...caller });
@@ -320,6 +417,12 @@ containerTest(
320417
(await callLoader({ kind: "run", id: siblingRun.friendlyId, userId: orgA.member.id })).status
321418
).toBe(401);
322419

420+
// A plain PAT authenticates a user but carries no actor claims, so it locates nothing.
421+
expect(
422+
(await callLoader({ kind: "run", id: siblingRun.friendlyId, bearer: "tr_pat_not_an_actor" }))
423+
.status
424+
).toBe(401);
425+
323426
// A claim naming an organization the caller doesn't belong to.
324427
const outsider = await callLoader({
325428
kind: "run",

0 commit comments

Comments
 (0)