Skip to content

Commit 0dcfb60

Browse files
committed
fix(webapp): mint a delegated token when a dashboard agent chat resumes
1 parent 1662a77 commit 0dcfb60

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
641641
chatId,
642642
clientData: {
643643
...pickAgentClientMetadata(clientData),
644+
// A resume replaces the session's stored metadata, so it has to carry a token
645+
// of its own: without one a run booting from it has no delegated access at all.
646+
userActorToken: await mintDashboardAgentUserActorToken(userId, {
647+
environmentId: runtimeEnv.id,
648+
organizationId: project.organizationId,
649+
}),
650+
apiOrigin: dashboardAgentUserApiOrigin(),
651+
projectRef: project.externalRef,
644652
organizationId: project.organizationId,
645653
userId,
646654
projectId: project.id,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Resuming a chat replaces the session's stored metadata, so the resume has to mint its own
3+
* delegated token — a run booting from token-less metadata has no access to anything.
4+
*
5+
* Real database and a real mint; only the boundaries this process can't stand up are stubbed
6+
* (the Trigger API the session start calls, the agent's own datastore, the dashboard session).
7+
*/
8+
9+
import { postgresTest } from "@internal/testcontainers";
10+
import type { PrismaClient } from "@trigger.dev/database";
11+
import { expect, vi } from "vitest";
12+
13+
const SESSION_SECRET = "test-session-secret-for-agent-resume";
14+
15+
const ctx = vi.hoisted(() => ({
16+
prisma: undefined as unknown as PrismaClient,
17+
userId: "",
18+
startedClientData: undefined as Record<string, unknown> | undefined,
19+
}));
20+
21+
vi.mock("~/db.server", () => {
22+
const proxy = new Proxy(
23+
{},
24+
{ get: (_target, prop) => (ctx.prisma as unknown as Record<string, unknown>)[prop as string] }
25+
);
26+
return { prisma: proxy, $replica: proxy, sqlDatabaseSchema: undefined };
27+
});
28+
vi.mock("~/env.server", () => ({
29+
env: {
30+
SESSION_SECRET: "test-session-secret-for-agent-resume",
31+
ENCRYPTION_KEY: "0123456789abcdef0123456789abcdef",
32+
APP_ORIGIN: "https://app.example.com",
33+
DASHBOARD_AGENT_SECRET_KEY: "tr_dev_agent",
34+
CLICKHOUSE_URL: "http://localhost:8123",
35+
},
36+
}));
37+
vi.mock("~/services/logger.server", () => ({
38+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
39+
}));
40+
vi.mock("~/services/session.server", () => ({
41+
requireUser: async () => ({ id: ctx.userId, admin: false, isImpersonating: false }),
42+
}));
43+
vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
44+
canAccessDashboardAgent: async () => true,
45+
}));
46+
vi.mock("~/services/clickhouse/clickhouseFactoryInstance.server", () => ({
47+
clickhouseFactory: {},
48+
}));
49+
vi.mock("~/services/dashboardAgentDb.server", () => ({ dashboardAgentDb: {} }));
50+
vi.mock("@internal/dashboard-agent-db", () => ({ chatExists: async () => true }));
51+
vi.mock("~/services/dashboardAgent.server", async (importOriginal) => {
52+
const original = await importOriginal<Record<string, unknown>>();
53+
return {
54+
...original,
55+
isDashboardAgentConfigured: () => true,
56+
startDashboardAgentSession: async (params: { clientData?: Record<string, unknown> }) => {
57+
ctx.startedClientData = params.clientData;
58+
return { publicAccessToken: "pat_public" };
59+
},
60+
};
61+
});
62+
63+
const { verifyUserActorToken } = await import("@trigger.dev/rbac");
64+
const { DASHBOARD_AGENT_UAT_CAP } = await import("~/services/dashboardAgent.server");
65+
const { action } =
66+
await import("~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent");
67+
68+
function suffix() {
69+
return Math.random().toString(36).slice(2, 10);
70+
}
71+
72+
async function seed(prisma: PrismaClient) {
73+
const slug = `resume_${suffix()}`;
74+
const user = await prisma.user.create({
75+
data: { email: `${slug}@example.com`, authenticationMethod: "MAGIC_LINK" },
76+
});
77+
const organization = await prisma.organization.create({ data: { title: slug, slug } });
78+
const orgMember = await prisma.orgMember.create({
79+
data: { organizationId: organization.id, userId: user.id, role: "ADMIN" },
80+
});
81+
const project = await prisma.project.create({
82+
data: { name: slug, slug, organizationId: organization.id, externalRef: `proj_${slug}` },
83+
});
84+
const environment = await prisma.runtimeEnvironment.create({
85+
data: {
86+
slug: "dev",
87+
type: "DEVELOPMENT",
88+
projectId: project.id,
89+
organizationId: organization.id,
90+
apiKey: `tr_dev_${slug}`,
91+
pkApiKey: `pk_dev_${slug}`,
92+
shortcode: `dev${suffix()}`,
93+
orgMemberId: orgMember.id,
94+
},
95+
});
96+
return { user, organization, project, environment };
97+
}
98+
99+
async function resume(params: { organizationSlug: string; projectParam: string }) {
100+
const body = new URLSearchParams({ intent: "start", chatId: "chat_1234" });
101+
const request = new Request("https://app.example.com/resources/dashboard-agent", {
102+
method: "POST",
103+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
104+
body,
105+
});
106+
return action({
107+
request,
108+
params: { ...params, envParam: "dev" },
109+
context: {} as any,
110+
});
111+
}
112+
113+
postgresTest("resuming a dashboard agent chat mints a delegated token", async ({ prisma }) => {
114+
ctx.prisma = prisma;
115+
const { user, organization, project, environment } = await seed(prisma);
116+
ctx.userId = user.id;
117+
118+
const response = await resume({
119+
organizationSlug: organization.slug,
120+
projectParam: project.slug,
121+
});
122+
expect(response.status).toBe(200);
123+
124+
const first = ctx.startedClientData;
125+
expect(typeof first?.userActorToken).toBe("string");
126+
// The token is only usable with the origin and project it is spent against.
127+
expect(first?.apiOrigin).toBe("https://app.example.com");
128+
expect(first?.projectRef).toBe(project.externalRef);
129+
130+
const claims = await verifyUserActorToken(SESSION_SECRET, first!.userActorToken as string);
131+
expect(claims).toMatchObject({
132+
userId: user.id,
133+
client: "dashboard-agent",
134+
environmentId: environment.id,
135+
organizationId: organization.id,
136+
cap: DASHBOARD_AGENT_UAT_CAP,
137+
});
138+
139+
// A second resume mints again, with the same scope — never a wider one.
140+
ctx.startedClientData = undefined;
141+
await resume({ organizationSlug: organization.slug, projectParam: project.slug });
142+
const second = await verifyUserActorToken(
143+
SESSION_SECRET,
144+
ctx.startedClientData!.userActorToken as string
145+
);
146+
expect(second).toMatchObject({
147+
userId: claims!.userId,
148+
environmentId: claims!.environmentId,
149+
organizationId: claims!.organizationId,
150+
cap: claims!.cap,
151+
});
152+
});

0 commit comments

Comments
 (0)