@@ -1092,6 +1092,11 @@ async def generate_cloud_uri(
10921092 status_code = 403 ,
10931093 detail = "You can only create apps for your own account unless you have admin permissions" ,
10941094 )
1095+ if not request .org_id :
1096+ raise HTTPException (
1097+ status_code = 400 ,
1098+ detail = "org_id is required when creating apps without an existing app token" ,
1099+ )
10951100 if not app_id :
10961101 app_id = str (uuid .uuid4 ())
10971102 except jwt .InvalidTokenError as e :
@@ -1191,6 +1196,16 @@ async def generate_cloud_uri(
11911196async def list_cloud_apps (
11921197 org_id : Optional [str ] = Query (default = None , description = "Filter apps by organization ID" ),
11931198 user_id : Optional [str ] = Query (default = None , description = "Filter apps by creator" ),
1199+ app_id_filter : Optional [str ] = Query (
1200+ default = None ,
1201+ description = "JSON filter expression for app IDs (supports $and/$or/$not/$nor and $eq/$ne/$gt/$gte/$lt/$lte/"
1202+ "$in/$nin/$exists/$regex/$contains)." ,
1203+ ),
1204+ app_name_filter : Optional [str ] = Query (
1205+ default = None ,
1206+ description = "JSON filter expression for app name (supports $and/$or/$not/$nor and $eq/$ne/$gt/$gte/$lt/$lte/"
1207+ "$in/$nin/$exists/$regex/$contains)." ,
1208+ ),
11941209 limit : int = Query (default = 100 , ge = 1 , le = 500 ),
11951210 offset : int = Query (default = 0 , ge = 0 ),
11961211 authorization : Optional [str ] = Header (default = None ),
@@ -1207,6 +1222,7 @@ async def list_cloud_apps(
12071222
12081223 token_user_id : Optional [str ] = None
12091224 token_permissions : List [str ] = []
1225+ token_app_id : Optional [str ] = None
12101226
12111227 if not is_admin_call :
12121228 if not authorization :
@@ -1233,26 +1249,65 @@ async def list_cloud_apps(
12331249
12341250 token_user_id = payload .get ("user_id" )
12351251 token_permissions = payload .get ("permissions" , []) or []
1252+ token_app_id = payload .get ("app_id" )
12361253
12371254 if not is_admin_call :
12381255 if user_id and user_id != token_user_id and "admin" not in token_permissions :
12391256 raise HTTPException (status_code = 403 , detail = "Cannot list apps for another user" )
12401257 if not user_id :
12411258 user_id = token_user_id
12421259
1260+ def _parse_filter_payload (value : Optional [str ], field_name : str ) -> Optional [Any ]:
1261+ if not value :
1262+ return None
1263+ try :
1264+ parsed = json .loads (value )
1265+ except json .JSONDecodeError as exc :
1266+ raise HTTPException (status_code = 400 , detail = f"{ field_name } must be valid JSON" ) from exc
1267+ if not isinstance (parsed , (dict , list )):
1268+ raise HTTPException (status_code = 400 , detail = f"{ field_name } must be a JSON object or array" )
1269+ return parsed
1270+
1271+ parsed_app_name_filter = _parse_filter_payload (app_name_filter , "app_name_filter" )
1272+ parsed_app_id_filter = _parse_filter_payload (app_id_filter , "app_id_filter" )
1273+
12431274 try :
12441275 from core .services .user_service import UserService
12451276
12461277 user_service = UserService ()
12471278 await user_service .initialize ()
1279+
1280+ resolved_org_id = org_id
1281+ if not is_admin_call :
1282+ token_org_id : Optional [str ] = None
1283+ if token_app_id :
1284+ token_app = await user_service .get_app_by_id (token_app_id )
1285+ token_org_id = token_app .get ("org_id" ) if token_app else None
1286+ else :
1287+ if not resolved_org_id :
1288+ raise HTTPException (status_code = 400 , detail = "org_id is required when listing apps without an app" )
1289+
1290+ if token_org_id :
1291+ if resolved_org_id and resolved_org_id != token_org_id :
1292+ raise HTTPException (status_code = 403 , detail = "Cannot list apps for another organization" )
1293+ resolved_org_id = token_org_id
1294+ user_id = None
1295+ elif token_app_id :
1296+ resolved_org_id = None
1297+ else :
1298+ user_id = None
12481299 apps = await user_service .list_apps (
1249- org_id = org_id ,
1300+ org_id = resolved_org_id ,
12501301 user_id = user_id ,
1302+ app_id_filter = parsed_app_id_filter ,
1303+ name_filter = parsed_app_name_filter ,
12511304 limit = limit ,
12521305 offset = offset ,
12531306 strict_org_scope = not is_admin_call ,
12541307 )
12551308 return {"apps" : apps , "count" : len (apps )}
1309+ except InvalidMetadataFilterError as exc :
1310+ raise HTTPException (status_code = 400 , detail = str (exc )) from exc
12561311 except HTTPException :
12571312 raise
12581313 except Exception as exc : # pragma: no cover - server failure
@@ -1283,6 +1338,7 @@ async def delete_cloud_app(
12831338 raise
12841339
12851340 user_id : Optional [str ] = None
1341+ token_app_id : Optional [str ] = None
12861342
12871343 if not is_admin_call :
12881344 # Require Bearer token if no admin secret
@@ -1299,36 +1355,41 @@ async def delete_cloud_app(
12991355 try :
13001356 payload = jwt .decode (token , settings .JWT_SECRET_KEY , algorithms = [settings .JWT_ALGORITHM ])
13011357 user_id = payload .get ("user_id" )
1358+ token_app_id = payload .get ("app_id" )
13021359 await ensure_app_is_active (
1303- payload . get ( "app_id" ) ,
1360+ token_app_id ,
13041361 token_version = payload .get ("token_version" ),
13051362 redis_pool = redis_pool ,
13061363 )
13071364 except jwt .InvalidTokenError as exc :
13081365 raise HTTPException (status_code = 401 , detail = str (exc )) from exc
1366+ if not token_app_id :
1367+ raise HTTPException (status_code = 403 , detail = "App-scoped token required to delete applications" )
13091368
13101369 logger .info (f"Deleting app { app_name } for user { user_id } (admin_call={ is_admin_call } )" )
13111370
13121371 from sqlalchemy import delete as sa_delete
13131372 from sqlalchemy import select
13141373
13151374 from core .models .apps import AppModel
1316- from core .services .user_service import UserService
13171375
13181376 # 1) Resolve app_id from apps table ----------------------------------
13191377 async with document_service .db .async_session () as session :
13201378 if is_admin_call :
13211379 # Admin call: look up by name only
13221380 stmt = select (AppModel ).where (AppModel .name == app_name )
13231381 else :
1324- # User call: look up by user_id and name
1325- stmt = select (AppModel ).where (AppModel .user_id == user_id , AppModel . name == app_name )
1382+ # App-scoped token call: look up by app_id only
1383+ stmt = select (AppModel ).where (AppModel .app_id == token_app_id )
13261384 res = await session .execute (stmt )
13271385 app_row = res .scalar_one_or_none ()
13281386
13291387 if app_row is None :
13301388 raise HTTPException (status_code = 404 , detail = "Application not found" )
13311389
1390+ if not is_admin_call and app_row .name != app_name :
1391+ raise HTTPException (status_code = 400 , detail = "Application name does not match token" )
1392+
13321393 app_id = app_row .app_id
13331394 # For admin calls, get user_id from the app record
13341395 effective_user_id = user_id if user_id else str (app_row .user_id )
@@ -1398,11 +1459,6 @@ async def delete_cloud_app(
13981459 await clear_app_active_cache (app_id , redis_pool = redis_pool )
13991460 await mark_app_revoked (app_id , redis_pool = redis_pool )
14001461
1401- # 5) Update user_limits --------------------------------------------
1402- user_service = UserService ()
1403- await user_service .initialize ()
1404- await user_service .unregister_app (effective_user_id , app_id )
1405-
14061462 return {
14071463 "app_name" : app_name ,
14081464 "status" : "deleted" ,
@@ -1430,6 +1486,7 @@ async def rotate_app_token(
14301486 raise
14311487
14321488 user_id : Optional [str ] = None
1489+ token_app_id : Optional [str ] = None
14331490 if not is_admin_call :
14341491 if not authorization :
14351492 raise HTTPException (
@@ -1444,34 +1501,44 @@ async def rotate_app_token(
14441501 try :
14451502 payload = jwt .decode (token , settings .JWT_SECRET_KEY , algorithms = [settings .JWT_ALGORITHM ])
14461503 user_id = payload .get ("user_id" )
1504+ token_app_id = payload .get ("app_id" )
14471505 await ensure_app_is_active (
1448- payload . get ( "app_id" ) ,
1506+ token_app_id ,
14491507 token_version = payload .get ("token_version" ),
14501508 redis_pool = redis_pool ,
14511509 )
14521510 except jwt .InvalidTokenError as exc :
14531511 raise HTTPException (status_code = 401 , detail = str (exc )) from exc
1512+ if not token_app_id :
1513+ raise HTTPException (status_code = 403 , detail = "App-scoped token required to rotate tokens" )
1514+
1515+ if app_id and app_id != token_app_id :
1516+ raise HTTPException (status_code = 403 , detail = "Cannot rotate token for another app" )
1517+ app_id = token_app_id
14541518
14551519 from sqlalchemy import select
14561520
14571521 from core .models .apps import AppModel
14581522 from core .services .user_service import UserService
14591523
14601524 async with document_service .db .async_session () as session :
1461- if app_id :
1462- stmt = select (AppModel ).where (AppModel .app_id == app_id )
1525+ if is_admin_call :
1526+ if app_id :
1527+ stmt = select (AppModel ).where (AppModel .app_id == app_id )
1528+ else :
1529+ stmt = select (AppModel ).where (AppModel .name == app_name )
14631530 else :
1464- stmt = select (AppModel ).where (AppModel .name == app_name )
1465-
1466- if not is_admin_call :
1467- stmt = stmt .where (AppModel .user_id == user_id )
1531+ stmt = select (AppModel ).where (AppModel .app_id == app_id )
14681532
14691533 res = await session .execute (stmt )
14701534 app_row = res .scalar_one_or_none ()
14711535
14721536 if app_row is None :
14731537 raise HTTPException (status_code = 404 , detail = "Application not found" )
14741538
1539+ if app_name and app_row .name != app_name :
1540+ raise HTTPException (status_code = 400 , detail = "Application name does not match token" )
1541+
14751542 effective_user_id = user_id
14761543 if not effective_user_id :
14771544 effective_user_id = str (app_row .user_id ) if app_row .user_id else app_row .created_by_user_id
@@ -1525,6 +1592,147 @@ async def rotate_app_token(
15251592 }
15261593
15271594
1595+ @app .patch ("/apps/rename" )
1596+ async def rename_cloud_app (
1597+ app_id : Optional [str ] = Query (default = None , description = "Application ID to rename" ),
1598+ app_name : Optional [str ] = Query (default = None , description = "Current application name to rename" ),
1599+ new_name : str = Query (..., description = "New application name" ),
1600+ authorization : Optional [str ] = Header (default = None ),
1601+ admin_secret : Optional [str ] = Header (default = None , alias = "X-Morphik-Admin-Secret" ),
1602+ redis_pool : Optional [arq .ArqRedis ] = Depends (get_optional_redis_pool ),
1603+ ) -> Dict [str , Any ]:
1604+ """Rename an existing cloud application."""
1605+ if not app_id and not app_name :
1606+ raise HTTPException (status_code = 400 , detail = "app_id or app_name is required" )
1607+
1608+ cleaned_name = new_name .strip ()
1609+ if not cleaned_name :
1610+ raise HTTPException (status_code = 400 , detail = "new_name is required" )
1611+ cleaned_name = cleaned_name .replace (" " , "_" ).lower ()
1612+
1613+ try :
1614+ is_admin_call = _validate_admin_secret (admin_secret )
1615+ except HTTPException :
1616+ raise
1617+
1618+ user_id : Optional [str ] = None
1619+ token_app_id : Optional [str ] = None
1620+ if not is_admin_call :
1621+ if not authorization :
1622+ raise HTTPException (
1623+ status_code = 401 ,
1624+ detail = "Missing authorization header" ,
1625+ headers = {"WWW-Authenticate" : "Bearer" },
1626+ )
1627+ if not authorization .startswith ("Bearer " ):
1628+ raise HTTPException (status_code = 401 , detail = "Invalid authorization header" )
1629+
1630+ token = authorization [7 :]
1631+ try :
1632+ payload = jwt .decode (token , settings .JWT_SECRET_KEY , algorithms = [settings .JWT_ALGORITHM ])
1633+ user_id = payload .get ("user_id" )
1634+ token_app_id = payload .get ("app_id" )
1635+ await ensure_app_is_active (
1636+ token_app_id ,
1637+ token_version = payload .get ("token_version" ),
1638+ redis_pool = redis_pool ,
1639+ )
1640+ except jwt .InvalidTokenError as exc :
1641+ raise HTTPException (status_code = 401 , detail = str (exc )) from exc
1642+
1643+ if not user_id :
1644+ raise HTTPException (status_code = 401 , detail = "Token is missing user_id" )
1645+ if not token_app_id :
1646+ raise HTTPException (status_code = 403 , detail = "App-scoped token required to rename applications" )
1647+
1648+ if app_id and app_id != token_app_id :
1649+ raise HTTPException (status_code = 403 , detail = "Cannot rename another application" )
1650+ app_id = token_app_id
1651+
1652+ from sqlalchemy import select
1653+ from sqlalchemy .exc import MultipleResultsFound
1654+
1655+ from core .models .apps import AppModel
1656+ from core .services .user_service import UserService
1657+
1658+ def _rename_uri (existing_uri : str , name : str ) -> str :
1659+ if not existing_uri :
1660+ logger .warning ("Missing app URI; leaving URI unchanged" )
1661+ return existing_uri
1662+
1663+ try :
1664+ rest = existing_uri .split ("morphik://" , 1 )[1 ]
1665+ _ , rest = rest .split (":" , 1 )
1666+ token , domain = rest .split ("@" , 1 )
1667+ except (IndexError , ValueError ):
1668+ logger .warning ("Unexpected app URI format; leaving URI unchanged: %s" , existing_uri )
1669+ return existing_uri
1670+
1671+ return f"morphik://{ name } :{ token } @{ domain } "
1672+
1673+ async with document_service .db .async_session () as session :
1674+ if is_admin_call :
1675+ if app_id :
1676+ stmt = select (AppModel ).where (AppModel .app_id == app_id )
1677+ else :
1678+ stmt = select (AppModel ).where (AppModel .name == app_name )
1679+ else :
1680+ stmt = select (AppModel ).where (AppModel .app_id == app_id )
1681+
1682+ try :
1683+ res = await session .execute (stmt )
1684+ app_row = res .scalar_one_or_none ()
1685+ except MultipleResultsFound as exc :
1686+ raise HTTPException (status_code = 409 , detail = "Multiple apps matched; use app_id to rename" ) from exc
1687+
1688+ if app_row is None :
1689+ raise HTTPException (status_code = 404 , detail = "Application not found" )
1690+
1691+ if app_name and app_row .name != app_name :
1692+ raise HTTPException (status_code = 400 , detail = "Application name does not match token" )
1693+
1694+ if app_row .name != cleaned_name :
1695+ name_stmt = select (AppModel .app_id ).where (AppModel .name == cleaned_name )
1696+ if app_row .org_id :
1697+ name_stmt = name_stmt .where (AppModel .org_id == app_row .org_id )
1698+ elif app_row .user_id :
1699+ name_stmt = name_stmt .where (AppModel .user_id == app_row .user_id )
1700+ name_stmt = name_stmt .where (AppModel .app_id != app_row .app_id )
1701+
1702+ res = await session .execute (name_stmt )
1703+ if res .scalar_one_or_none () is not None :
1704+ raise HTTPException (status_code = 409 , detail = f"App with name '{ cleaned_name } ' already exists" )
1705+
1706+ app_row .name = cleaned_name
1707+ app_row .uri = _rename_uri (app_row .uri , cleaned_name )
1708+ await session .commit ()
1709+
1710+ app_id_value = app_row .app_id
1711+ app_name_value = app_row .name
1712+ app_uri_value = app_row .uri
1713+ org_id_value = app_row .org_id
1714+ created_by_user_id_value = app_row .created_by_user_id
1715+ app_user_id_value = str (app_row .user_id ) if app_row .user_id else None
1716+
1717+ # Sync to control plane for dashboard visibility (user_id is optional)
1718+ effective_user_id = user_id or app_user_id_value or created_by_user_id_value
1719+ user_service = UserService ()
1720+ await user_service ._sync_app_to_control_plane (
1721+ app_id = app_id_value ,
1722+ user_id = effective_user_id ,
1723+ org_id = org_id_value ,
1724+ created_by_user_id = created_by_user_id_value ,
1725+ name = app_name_value ,
1726+ uri = app_uri_value ,
1727+ )
1728+
1729+ return {
1730+ "app_id" : app_id_value ,
1731+ "app_name" : app_name_value ,
1732+ "uri" : app_uri_value ,
1733+ }
1734+
1735+
15281736@app .get ("/chats" , response_model = List [Dict [str , Any ]])
15291737async def list_chat_conversations (
15301738 auth : AuthContext = Depends (verify_token ),
0 commit comments