11import { type ActionFunctionArgs , json } from "@remix-run/node" ;
22import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3" ;
3+ import { isUserActorToken , verifyUserActorToken , type UserActorClaims } from "@trigger.dev/rbac" ;
34import { z } from "zod" ;
45import {
56 authenticatedEnvironmentForAuthentication ,
7+ authenticateRequest ,
68 branchNameFromRequest ,
9+ type AuthenticationResult ,
710} from "~/services/apiAuth.server" ;
11+ import { env as appEnv } from "~/env.server" ;
12+ import { assertUserActorEnvironment } from "~/services/userActorEnvironment.server" ;
813import { logger } from "~/services/logger.server" ;
914import { authorizePatEnvironmentAccess } from "~/services/environmentVariableApiAccess.server" ;
10- import { authenticateUatOrApiRequest } from "~/services/uatRoutePreamble.server" ;
11- import { rbac } from "~/services/rbac.server" ;
12- import {
13- assertUserActorEnvironment ,
14- clampUserActorScopes ,
15- } from "~/services/userActorEnvironment.server" ;
1615
1716const ParamsSchema = z . object ( {
1817 projectRef : z . string ( ) ,
@@ -30,17 +29,46 @@ const RequestBodySchema = z.object({
3029
3130export async function action ( { request, params } : ActionFunctionArgs ) {
3231 try {
33- // A user-actor token authenticates as its user, like a PAT. Its scope cap ceilings the
32+ const bearer = request . headers
33+ . get ( "Authorization" )
34+ ?. replace ( / ^ B e a r e r / , "" )
35+ . trim ( ) ;
36+ const isUat = ! ! bearer && isUserActorToken ( bearer ) ;
37+
38+ // A delegated user-actor token authenticates as its user, like a PAT. We
39+ // resolve it here (not through authenticateRequest) so the exchange stays
40+ // scoped to this route — UATs deliberately aren't accepted on every
41+ // PAT route. `uatCap` (the token's optional scope cap) ceilings the
3442 // minted env JWT below.
35- const authentication = await authenticateUatOrApiRequest ( request ) ;
43+ let uatCap : string [ ] | undefined ;
44+ let userActorId : string | undefined ;
45+ let userActor : UserActorClaims | undefined ;
46+ let authenticationResult : AuthenticationResult | undefined ;
47+ if ( isUat ) {
48+ const claims = await verifyUserActorToken ( appEnv . SESSION_SECRET , bearer ! ) ;
49+ if ( ! claims ) {
50+ return json ( { error : "Invalid or Missing Access Token" } , { status : 401 } ) ;
51+ }
52+ uatCap = claims . cap ;
53+ userActorId = claims . userId ;
54+ userActor = claims ;
55+ // The env lookup keys purely on the user, identical to a PAT.
56+ authenticationResult = {
57+ type : "personalAccessToken" ,
58+ result : { userId : claims . userId } ,
59+ } ;
60+ } else {
61+ authenticationResult = await authenticateRequest ( request , {
62+ personalAccessToken : true ,
63+ organizationAccessToken : true ,
64+ apiKey : false ,
65+ } ) ;
66+ }
3667
37- if ( ! authentication ) {
68+ if ( ! authenticationResult ) {
3869 return json ( { error : "Invalid or Missing Access Token" } , { status : 401 } ) ;
3970 }
4071
41- const { authenticationResult, userActor } = authentication ;
42- const userActorId = userActor ?. userId ;
43-
4472 const parsedParams = ParamsSchema . safeParse ( params ) ;
4573
4674 if ( ! parsedParams . success ) {
@@ -57,7 +85,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
5785 triggerBranch
5886 ) ;
5987
60- // The exchange only ever mints for the environment the token was signed for .
88+ // A user-actor token signed for one environment mints only for that one .
6189 assertUserActorEnvironment ( userActor , runtimeEnv . id ) ;
6290
6391 // This mints a JWT signed with the environment's secret key. For a PAT
@@ -83,32 +111,21 @@ export async function action({ request, params }: ActionFunctionArgs) {
83111 ) ;
84112 }
85113
86- // The env JWT carries scopes only — downstream auth builds its ability from them with no role
87- // context. So for a user-actor token the ceiling is the actor's own ability (role floor ∩ the
88- // token's cap), never the request: a delegated token can't mint a credential more capable than
89- // itself, and a capless token is read-only rather than unbounded. A PAT passes through, gated
90- // by the env-tier check above.
114+ // The env JWT carries scopes only — downstream auth builds its ability
115+ // from them with no role context. So for a user-actor token we ceiling
116+ // the scopes by the token's own cap here (a read-only agent token can't
117+ // widen its grant through the exchange) and stamp the user via `act` so
118+ // the minted env JWT stays attributable. The cap is a ceiling, not a
119+ // replacement: intersect what the caller asked for with the cap (or use
120+ // the full cap if they asked for nothing). No cap → the request passes
121+ // through, same as a PAT.
91122 const requestedScopes = parsedBody . data . claims ?. scopes ;
92- let scopes = requestedScopes ;
93-
94- if ( userActor ) {
95- const actorAuth = await rbac . authenticateUserActor ( request , {
96- organizationId : runtimeEnv . organizationId ,
97- projectId : runtimeEnv . project . id ,
98- } ) ;
99- if ( ! actorAuth . ok ) {
100- return json ( { error : actorAuth . error } , { status : actorAuth . status } ) ;
101- }
102-
103- const clamped = clampUserActorScopes ( requestedScopes , userActor , actorAuth . ability ) ;
104- if ( clamped . scopes . length === 0 ) {
105- return json (
106- { error : "This token isn't allowed the requested scopes" , scopes : clamped . deniedScopes } ,
107- { status : 403 }
108- ) ;
109- }
110- scopes = clamped . scopes ;
111- }
123+ const scopes =
124+ isUat && uatCap
125+ ? requestedScopes && requestedScopes . length > 0
126+ ? requestedScopes . filter ( ( scope ) => uatCap . includes ( scope ) )
127+ : uatCap
128+ : requestedScopes ;
112129
113130 // Attribution: stamp the acting user on the minted env JWT. A UAT carries
114131 // its user as `userActorId`; a PAT exchange resolves the user from the
@@ -120,13 +137,14 @@ export async function action({ request, params }: ActionFunctionArgs) {
120137 ( authenticationResult . type === "personalAccessToken"
121138 ? authenticationResult . result . userId
122139 : undefined ) ;
123- const actorClient = userActor ?. client ?? "personal-access-token" ;
124140
125141 const claims = {
126142 sub : runtimeEnv . id ,
127143 pub : true ,
128144 ...( scopes ? { scopes } : { } ) ,
129- ...( actorUserId ? { act : { sub : actorUserId , client : actorClient } } : { } ) ,
145+ ...( actorUserId
146+ ? { act : { sub : actorUserId , client : userActor ?. client ?? "personal-access-token" } }
147+ : { } ) ,
130148 } ;
131149
132150 const jwt = await internal_generateJWT ( {
0 commit comments