@@ -795,4 +795,228 @@ describe("API", () => {
795795 expect ( res . status ) . not . toBe ( 403 ) ;
796796 } ) ;
797797 } ) ;
798+
799+ // Run lists (TRI-8736). Two routes share the same multi-key
800+ // resource pattern — collection-level `{ type: "runs" }` always
801+ // present, plus an array of secondary keys derived from search
802+ // params:
803+ // - GET /api/v1/runs: filter[taskIdentifier]=A,B → +{ type: "tasks", id: A }, { type: "tasks", id: B }
804+ // - GET /realtime/v1/runs: ?tags=foo,bar → +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" }
805+ //
806+ // Multi-key any-match contract from TRI-8719: a JWT with a scope
807+ // matching ANY element of the resource array grants access. So:
808+ // - read:runs → matches the collection key → passes
809+ // - read:tasks:A (with A in filter) → matches an array element → passes
810+ // - read:tasks:Z (with A in filter) → no match → 403
811+ describe ( "Run list — api.v1.runs (multi-key tasks)" , ( ) => {
812+ const path = "/api/v1/runs" ;
813+
814+ async function get ( query : string , headers : Record < string , string > ) {
815+ return getTestServer ( ) . webapp . fetch ( `${ path } ${ query } ` , { headers } ) ;
816+ }
817+
818+ it ( "missing auth: 401" , async ( ) => {
819+ const res = await getTestServer ( ) . webapp . fetch ( path ) ;
820+ expect ( res . status ) . toBe ( 401 ) ;
821+ } ) ;
822+
823+ it ( "private API key: 200" , async ( ) => {
824+ const server = getTestServer ( ) ;
825+ const seed = await seedTestEnvironment ( server . prisma ) ;
826+ const res = await get ( "" , { Authorization : `Bearer ${ seed . apiKey } ` } ) ;
827+ expect ( res . status ) . toBe ( 200 ) ;
828+ } ) ;
829+
830+ it ( "JWT with read:runs (collection-level): 200" , async ( ) => {
831+ const server = getTestServer ( ) ;
832+ const seed = await seedTestEnvironment ( server . prisma ) ;
833+ const jwt = await generateJWT ( {
834+ secretKey : seed . apiKey ,
835+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:runs" ] } ,
836+ expirationTime : "15m" ,
837+ } ) ;
838+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
839+ expect ( res . status ) . toBe ( 200 ) ;
840+ } ) ;
841+
842+ it ( "JWT with read:all super-scope: 200" , async ( ) => {
843+ const server = getTestServer ( ) ;
844+ const seed = await seedTestEnvironment ( server . prisma ) ;
845+ const jwt = await generateJWT ( {
846+ secretKey : seed . apiKey ,
847+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:all" ] } ,
848+ expirationTime : "15m" ,
849+ } ) ;
850+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
851+ expect ( res . status ) . toBe ( 200 ) ;
852+ } ) ;
853+
854+ it ( "JWT with admin: 200" , async ( ) => {
855+ const server = getTestServer ( ) ;
856+ const seed = await seedTestEnvironment ( server . prisma ) ;
857+ const jwt = await generateJWT ( {
858+ secretKey : seed . apiKey ,
859+ payload : { pub : true , sub : seed . environment . id , scopes : [ "admin" ] } ,
860+ expirationTime : "15m" ,
861+ } ) ;
862+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
863+ expect ( res . status ) . toBe ( 200 ) ;
864+ } ) ;
865+
866+ it ( "JWT with empty scopes: 403" , async ( ) => {
867+ const server = getTestServer ( ) ;
868+ const seed = await seedTestEnvironment ( server . prisma ) ;
869+ const jwt = await generateJWT ( {
870+ secretKey : seed . apiKey ,
871+ payload : { pub : true , sub : seed . environment . id , scopes : [ ] } ,
872+ expirationTime : "15m" ,
873+ } ) ;
874+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
875+ expect ( res . status ) . toBe ( 403 ) ;
876+ } ) ;
877+
878+ it ( "JWT with write:runs (action mismatch — read route): 403" , async ( ) => {
879+ const server = getTestServer ( ) ;
880+ const seed = await seedTestEnvironment ( server . prisma ) ;
881+ const jwt = await generateJWT ( {
882+ secretKey : seed . apiKey ,
883+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:runs" ] } ,
884+ expirationTime : "15m" ,
885+ } ) ;
886+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
887+ expect ( res . status ) . toBe ( 403 ) ;
888+ } ) ;
889+
890+ it ( "filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)" , async ( ) => {
891+ const server = getTestServer ( ) ;
892+ const seed = await seedTestEnvironment ( server . prisma ) ;
893+ const jwt = await generateJWT ( {
894+ secretKey : seed . apiKey ,
895+ payload : {
896+ pub : true ,
897+ sub : seed . environment . id ,
898+ scopes : [ "read:tasks:task_a" ] ,
899+ } ,
900+ expirationTime : "15m" ,
901+ } ) ;
902+ const res = await get (
903+ "?filter%5BtaskIdentifier%5D=task_a%2Ctask_b" ,
904+ { Authorization : `Bearer ${ jwt } ` }
905+ ) ;
906+ // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}].
907+ // The scope read:tasks:task_a matches the second element → access granted.
908+ expect ( res . status ) . toBe ( 200 ) ;
909+ } ) ;
910+
911+ it ( "filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)" , async ( ) => {
912+ const server = getTestServer ( ) ;
913+ const seed = await seedTestEnvironment ( server . prisma ) ;
914+ const jwt = await generateJWT ( {
915+ secretKey : seed . apiKey ,
916+ payload : {
917+ pub : true ,
918+ sub : seed . environment . id ,
919+ scopes : [ "read:tasks:task_z" ] ,
920+ } ,
921+ expirationTime : "15m" ,
922+ } ) ;
923+ const res = await get (
924+ "?filter%5BtaskIdentifier%5D=task_a" ,
925+ { Authorization : `Bearer ${ jwt } ` }
926+ ) ;
927+ // Resource is [{runs}, {tasks:task_a}]. JWT scope says
928+ // read:tasks:task_z which doesn't match the runs collection
929+ // (wrong type) or the task_a element (wrong id). 403.
930+ expect ( res . status ) . toBe ( 403 ) ;
931+ } ) ;
932+ } ) ;
933+
934+ describe ( "Run list — realtime.v1.runs (multi-key tags)" , ( ) => {
935+ const path = "/realtime/v1/runs" ;
936+
937+ async function get ( query : string , headers : Record < string , string > ) {
938+ return getTestServer ( ) . webapp . fetch ( `${ path } ${ query } ` , { headers } ) ;
939+ }
940+
941+ it ( "missing auth: 401" , async ( ) => {
942+ const res = await getTestServer ( ) . webapp . fetch ( path ) ;
943+ expect ( res . status ) . toBe ( 401 ) ;
944+ } ) ;
945+
946+ it ( "JWT with read:runs (collection-level): auth passes" , async ( ) => {
947+ const server = getTestServer ( ) ;
948+ const seed = await seedTestEnvironment ( server . prisma ) ;
949+ const jwt = await generateJWT ( {
950+ secretKey : seed . apiKey ,
951+ payload : { pub : true , sub : seed . environment . id , scopes : [ "read:runs" ] } ,
952+ expirationTime : "15m" ,
953+ } ) ;
954+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
955+ // Realtime endpoints stream — the route may return 200 (streaming
956+ // OK) or other status codes depending on streams setup. We only
957+ // care that auth passed: NOT 401/403.
958+ expect ( res . status ) . not . toBe ( 401 ) ;
959+ expect ( res . status ) . not . toBe ( 403 ) ;
960+ } ) ;
961+
962+ it ( "JWT with read:tags:foo + ?tags=foo,bar → passes (array match)" , async ( ) => {
963+ const server = getTestServer ( ) ;
964+ const seed = await seedTestEnvironment ( server . prisma ) ;
965+ const jwt = await generateJWT ( {
966+ secretKey : seed . apiKey ,
967+ payload : {
968+ pub : true ,
969+ sub : seed . environment . id ,
970+ scopes : [ "read:tags:foo" ] ,
971+ } ,
972+ expirationTime : "15m" ,
973+ } ) ;
974+ const res = await get ( "?tags=foo,bar" , { Authorization : `Bearer ${ jwt } ` } ) ;
975+ // Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}].
976+ // Scope matches the foo element → access granted.
977+ expect ( res . status ) . not . toBe ( 401 ) ;
978+ expect ( res . status ) . not . toBe ( 403 ) ;
979+ } ) ;
980+
981+ it ( "JWT with read:tags:baz + ?tags=foo → 403 (no array match)" , async ( ) => {
982+ const server = getTestServer ( ) ;
983+ const seed = await seedTestEnvironment ( server . prisma ) ;
984+ const jwt = await generateJWT ( {
985+ secretKey : seed . apiKey ,
986+ payload : {
987+ pub : true ,
988+ sub : seed . environment . id ,
989+ scopes : [ "read:tags:baz" ] ,
990+ } ,
991+ expirationTime : "15m" ,
992+ } ) ;
993+ const res = await get ( "?tags=foo" , { Authorization : `Bearer ${ jwt } ` } ) ;
994+ expect ( res . status ) . toBe ( 403 ) ;
995+ } ) ;
996+
997+ it ( "JWT with admin: auth passes" , async ( ) => {
998+ const server = getTestServer ( ) ;
999+ const seed = await seedTestEnvironment ( server . prisma ) ;
1000+ const jwt = await generateJWT ( {
1001+ secretKey : seed . apiKey ,
1002+ payload : { pub : true , sub : seed . environment . id , scopes : [ "admin" ] } ,
1003+ expirationTime : "15m" ,
1004+ } ) ;
1005+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
1006+ expect ( res . status ) . not . toBe ( 401 ) ;
1007+ expect ( res . status ) . not . toBe ( 403 ) ;
1008+ } ) ;
1009+
1010+ it ( "JWT with write:runs (action mismatch): 403" , async ( ) => {
1011+ const server = getTestServer ( ) ;
1012+ const seed = await seedTestEnvironment ( server . prisma ) ;
1013+ const jwt = await generateJWT ( {
1014+ secretKey : seed . apiKey ,
1015+ payload : { pub : true , sub : seed . environment . id , scopes : [ "write:runs" ] } ,
1016+ expirationTime : "15m" ,
1017+ } ) ;
1018+ const res = await get ( "" , { Authorization : `Bearer ${ jwt } ` } ) ;
1019+ expect ( res . status ) . toBe ( 403 ) ;
1020+ } ) ;
1021+ } ) ;
7981022} ) ;
0 commit comments