Skip to content

Commit 8506877

Browse files
committed
merge: propagate wave-2 review fixes from feat/dashboard-agent-flows
2 parents 99bbc07 + 876ed56 commit 8506877

25 files changed

Lines changed: 1035 additions & 83 deletions

.changeset/uat-ttl-help-text.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"trigger.dev": patch
3+
---
4+
5+
The `mint-token` command's `--ttl` help now shows the correct maximum token lifetime of 7 days.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
The dashboard agent now treats captured content it reads — run logs, error messages, and commit messages — as data, so instructions hidden inside them can no longer steer the assistant.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Deleted assistant conversations are now permanently removed after a grace period, and deleting an organization also removes its conversations.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Revoking a personal access token now also stops any short-lived tokens created from it. Tokens created from a personal access token can now live for at most 7 days.

apps/webapp/app/routes/api.v1.auth.user-actor-token.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { rbac } from "~/services/rbac.server";
99
// with the PAT. The default is short, but the ceiling allows long-lived tokens
1010
// for callers that need them (e.g. a long-running integration).
1111
const DEFAULT_UAT_TTL_SECONDS = 60 * 60; // 1 hour
12-
const MAX_UAT_TTL_SECONDS = 365 * 24 * 60 * 60; // 365 days
12+
const MAX_UAT_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days
1313

1414
// Mint a short-lived delegated user-actor token (`tr_uat_`) from a personal
1515
// access token. A UAT is a strict downgrade of the PAT: same user identity,
@@ -68,6 +68,8 @@ export async function action({ request }: ActionFunctionArgs) {
6868
const token = await signUserActorToken(env.SESSION_SECRET, {
6969
userId: patAuth.userId,
7070
client: body.client ?? "personal-access-token",
71+
// Bind the token to its source PAT so revoking the PAT invalidates it.
72+
pat: patAuth.tokenId,
7173
cap: body.cap,
7274
// Absolute exp (seconds since epoch). jose treats a number as absolute.
7375
expirationTime: Math.floor(Date.now() / 1000) + ttlSeconds,

apps/webapp/app/routes/api.v1.projects.$projectRef.$env.jwt.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
} from "~/services/apiAuth.server";
1919
import { env as appEnv } from "~/env.server";
2020
import { assertUserActorEnvironment } from "~/services/userActorEnvironment.server";
21+
import { assertSourcePatActive } from "~/services/personalAccessToken.server";
2122
import { logger } from "~/services/logger.server";
2223
import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server";
2324

@@ -78,6 +79,10 @@ export async function action({ request, params }: ActionFunctionArgs) {
7879
if (!claims) {
7980
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
8081
}
82+
// A token minted from a PAT dies with it — the PAT must still be live.
83+
if (!(await assertSourcePatActive(claims))) {
84+
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
85+
}
8186
uatCap = claims.cap;
8287
userActorId = claims.userId;
8388
userActor = claims;

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

Lines changed: 68 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,73 @@ import type { Duration } from "./rateLimiter.server";
88

99
const BATCH_STREAM_ITEMS_PATH = /^\/api\/v3\/batches\/([^/]+)\/items$/;
1010

11+
// Rate-limit key for a delegated (agent/PAT-minted) JWT. Its token value rotates every
12+
// turn, so keying on the token would hand each turn a fresh bucket. Key on env+acting-user
13+
// so the agent's traffic shares one bucket across turns. The `jwt-actor:` prefix keeps it
14+
// off PRIVATE-key buckets, which key on the bare environment id.
15+
export function jwtActorRateLimitIdentifier(environmentId: string, actorSub: string): string {
16+
return `jwt-actor:${environmentId}:${actorSub}`;
17+
}
18+
19+
// The per-request bucket decision for the API limiter. Exported so the branch below
20+
// (a delegated JWT keys on env+acting-user, everything else keeps its prior key) is
21+
// testable without standing up the middleware and its Redis.
22+
export async function resolveApiRateLimitOverride(
23+
authorizationValue: string
24+
): Promise<{ config?: unknown; identifier?: string } | undefined> {
25+
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
26+
27+
if (rawApiKey.startsWith("tr_")) {
28+
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);
29+
30+
if (!scope) {
31+
return;
32+
}
33+
34+
return {
35+
config: scope.apiRateLimiterConfig,
36+
identifier: scope.environmentId,
37+
};
38+
}
39+
40+
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
41+
allowPublicKey: true,
42+
allowJWT: true,
43+
});
44+
45+
if (!authenticatedEnv || !authenticatedEnv.ok) {
46+
return;
47+
}
48+
49+
if (authenticatedEnv.type === "PUBLIC_JWT") {
50+
const config = {
51+
type: "fixedWindow",
52+
window: env.API_RATE_LIMIT_JWT_WINDOW,
53+
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
54+
} as const;
55+
56+
// A delegated JWT (agent/PAT-minted) shares one bucket per env+acting-user across turns.
57+
// A browser realtime JWT carries no `act`, so it keeps the hashed-token fallback.
58+
if (authenticatedEnv.actor?.sub) {
59+
return {
60+
config,
61+
identifier: jwtActorRateLimitIdentifier(
62+
authenticatedEnv.environment.id,
63+
authenticatedEnv.actor.sub
64+
),
65+
};
66+
}
67+
68+
return { config };
69+
}
70+
71+
return {
72+
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
73+
// Public keys are browser-distributed, so keep them on per-key buckets.
74+
identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined,
75+
};
76+
}
77+
1178
export const apiRateLimiter = authorizationRateLimitMiddleware({
1279
redis: {
1380
port: env.RATE_LIMIT_REDIS_PORT,
@@ -29,47 +96,7 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
2996
stale: 60_000 * 20, // Date is stale after 20 minutes
3097
maxItems: 1000,
3198
},
32-
limiterConfigOverride: async (authorizationValue) => {
33-
const rawApiKey = authorizationValue.replace(/^Bearer /, "");
34-
35-
if (rawApiKey.startsWith("tr_")) {
36-
const scope = await resolvePrivateApiKeyRateLimitScope(rawApiKey);
37-
38-
if (!scope) {
39-
return;
40-
}
41-
42-
return {
43-
config: scope.apiRateLimiterConfig,
44-
identifier: scope.environmentId,
45-
};
46-
}
47-
48-
const authenticatedEnv = await authenticateAuthorizationHeader(authorizationValue, {
49-
allowPublicKey: true,
50-
allowJWT: true,
51-
});
52-
53-
if (!authenticatedEnv || !authenticatedEnv.ok) {
54-
return;
55-
}
56-
57-
if (authenticatedEnv.type === "PUBLIC_JWT") {
58-
return {
59-
config: {
60-
type: "fixedWindow",
61-
window: env.API_RATE_LIMIT_JWT_WINDOW,
62-
tokens: env.API_RATE_LIMIT_JWT_TOKENS,
63-
},
64-
};
65-
}
66-
67-
return {
68-
config: authenticatedEnv.environment.organization.apiRateLimiterConfig,
69-
// Public keys are browser-distributed, so keep them on per-key buckets.
70-
identifier: authenticatedEnv.type === "PRIVATE" ? authenticatedEnv.environment.id : undefined,
71-
};
72-
},
99+
limiterConfigOverride: resolveApiRateLimitOverride,
73100
pathMatchers: [/^\/api/],
74101
// Allow /api/v1/tasks/:id/callback/:secret
75102
pathWhiteList: [
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Retention for soft-deleted chats. A deleted chat is kept for a grace window and then
3+
* hard-deleted with all its child rows; one bounded statement per run, oldest first.
4+
* Also the eventual purge behind organization deletion, which soft-deletes the org's
5+
* chats so this same sweep removes them.
6+
*/
7+
8+
import {
9+
hardDeleteChatsSoftDeletedBefore,
10+
softDeleteChatsForOrganization,
11+
} from "@internal/dashboard-agent-db";
12+
import { dashboardAgentDb } from "~/services/dashboardAgentDb.server";
13+
import { logger } from "~/services/logger.server";
14+
15+
/**
16+
* How long a soft-deleted chat is kept before it and its children are hard-deleted.
17+
* Long enough that an accidental delete can still be investigated; org deletion soft-
18+
* deletes the org's chats, so those are removed the same way once the window passes.
19+
*/
20+
export const CHAT_SOFT_DELETE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
21+
22+
/** Per-run cap. Retention is one bounded statement, not a row-at-a-time loop. */
23+
const RETENTION_BATCH_LIMIT = 500;
24+
25+
export type ChatRetentionResult = {
26+
/** Soft-deleted chats past the retention window dropped this run. */
27+
purged: number;
28+
failed: number;
29+
};
30+
31+
export type ChatRetentionDeps = {
32+
now?: () => Date;
33+
limit?: number;
34+
/** Hard-delete chats soft-deleted before `before`. Returns how many went. */
35+
purge?: (params: { before: Date; limit: number }) => Promise<number>;
36+
};
37+
38+
export async function sweepDashboardAgentSoftDeletedChats(
39+
deps: ChatRetentionDeps = {}
40+
): Promise<ChatRetentionResult> {
41+
const now = deps.now?.() ?? new Date();
42+
const limit = deps.limit ?? RETENTION_BATCH_LIMIT;
43+
const purge =
44+
deps.purge ?? ((params) => hardDeleteChatsSoftDeletedBefore(dashboardAgentDb, params));
45+
46+
const result: ChatRetentionResult = { purged: 0, failed: 0 };
47+
48+
try {
49+
result.purged = await purge({
50+
before: new Date(now.getTime() - CHAT_SOFT_DELETE_RETENTION_MS),
51+
limit,
52+
});
53+
} catch (error) {
54+
result.failed++;
55+
logger.error("Dashboard agent chat retention failed", { error });
56+
}
57+
58+
if (result.failed > 0) {
59+
throw new Error("The dashboard agent chat retention pass failed");
60+
}
61+
62+
return result;
63+
}
64+
65+
/**
66+
* Soft-delete every chat belonging to a deleted organization. The retention sweep above
67+
* hard-deletes them once the window passes, so the org-deletion request never runs a
68+
* cross-database hard delete.
69+
*/
70+
export async function purgeDashboardAgentChatsForOrganization(params: {
71+
organizationId: string;
72+
}): Promise<number> {
73+
return softDeleteChatsForOrganization(dashboardAgentDb, params);
74+
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { featuresForRequest } from "~/features.server";
55
import { DeleteProjectService } from "./deleteProject.server";
66
import { getCurrentPlan } from "./platform.v3.server";
77
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
8+
import { commonWorker } from "~/v3/commonWorker.server";
89

910
export class DeleteOrganizationService {
1011
#prismaClient: PrismaClient;
@@ -86,5 +87,13 @@ export class DeleteOrganizationService {
8687

8788
// runsEnabled + the org's projects (project.deletedAt) changed; drop all cached env rows.
8889
controlPlaneResolver.invalidateOrganization(organization.id);
90+
91+
// Soft-delete the org's dashboard agent chats; retention purges them later. Enqueued,
92+
// not inline: the agent store is a separate database in cloud.
93+
await commonWorker.enqueue({
94+
id: `dashboardAgent.purgeOrganization:${organization.id}`,
95+
job: "dashboardAgent.purgeOrganization",
96+
payload: { organizationId: organization.id },
97+
});
8998
}
9099
}

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { logger } from "./logger.server";
66
import { rbac } from "./rbac.server";
77
import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server";
88
import { env } from "~/env.server";
9-
import { isUserActorToken, type UserActorClaims } from "@trigger.dev/rbac";
9+
import { isUserActorToken, verifyUserActorToken, type UserActorClaims } from "@trigger.dev/rbac";
1010

1111
const tokenValueLength = 40;
1212
//lowercase only, removed 0 and l to avoid confusion
@@ -290,6 +290,41 @@ export function isPersonalAccessToken(token: string) {
290290
return token.startsWith(tokenPrefix);
291291
}
292292

293+
/**
294+
* A user-actor token minted from a PAT carries the source PAT's id (`claims.pat`).
295+
* The token is stateless, so revoking the PAT can't invalidate it by itself — hosts
296+
* recheck the source PAT is still live here. A token with no `pat` (e.g. the dashboard
297+
* agent's) has no source to check and is left alone.
298+
*/
299+
export async function assertSourcePatActive(claims: UserActorClaims): Promise<boolean> {
300+
if (!claims.pat) return true;
301+
302+
const found = await prisma.personalAccessToken.findFirst({
303+
where: { id: claims.pat, revokedAt: null },
304+
select: { id: true },
305+
});
306+
return Boolean(found);
307+
}
308+
309+
/**
310+
* Resolve + source-PAT-recheck a user-actor token for a verify site where `claims` may be
311+
* supplied by the RBAC plugin (the apiBuilder path). We re-verify the bearer locally so the
312+
* recheck reads the token's OWN `pat`, not the plugin's: a plugin image predating the `pat`
313+
* claim would deliver pat-less claims and silently no-op revocation here. Returns the claims
314+
* to act on, or undefined to deny. The direct verify sites (jwt exchange, the UAT preamble)
315+
* don't go through a plugin and call `assertSourcePatActive` on their own verified claims.
316+
*/
317+
export async function resolveAndRecheckUserActorClaims(
318+
claims: UserActorClaims | undefined,
319+
bearer: string
320+
): Promise<UserActorClaims | undefined> {
321+
const verified = await verifyUserActorToken(env.SESSION_SECRET, bearer);
322+
const resolved = claims ?? verified;
323+
if (!resolved) return undefined;
324+
// Recheck against the locally-verified claims when available, so `pat` is authoritative.
325+
return (await assertSourcePatActive(verified ?? resolved)) ? resolved : undefined;
326+
}
327+
293328
/**
294329
* Read-only check that an authorization code is still mintable: it exists, is
295330
* unconsumed (`personalAccessTokenId: null`), and within the TTL. Lets the

0 commit comments

Comments
 (0)